From 3d95d43fd7769d998038ea5079d0b637c57c96ac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 28 Jun 2018 15:01:44 -0700 Subject: [PATCH 001/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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/327] 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 1e17dad62135385c92ac58cf9588d9a44457a033 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Apr 2019 15:09:51 -0700 Subject: [PATCH 148/327] add optional metric value to track() --- .../java/com/launchdarkly/client/Event.java | 8 ++++++- .../com/launchdarkly/client/EventFactory.java | 4 ++-- .../com/launchdarkly/client/EventOutput.java | 7 +++++-- .../com/launchdarkly/client/LDClient.java | 21 +++++++++++-------- .../client/LDClientInterface.java | 16 +++++++++++--- .../client/DefaultEventProcessorTest.java | 12 +++++++---- .../client/EventSummarizerTest.java | 2 +- 7 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/Event.java index a65a27d01..8d10f6222 100644 --- a/src/main/java/com/launchdarkly/client/Event.java +++ b/src/main/java/com/launchdarkly/client/Event.java @@ -17,11 +17,17 @@ public Event(long creationDate, LDUser user) { public static final class Custom extends Event { final String key; final JsonElement data; + final Double metricValue; - public Custom(long timestamp, String key, LDUser user, JsonElement data) { + public Custom(long timestamp, String key, LDUser user, JsonElement data, Double metricValue) { super(timestamp, user); this.key = key; this.data = data; + this.metricValue = metricValue; + } + + public Custom(long timestamp, String key, LDUser user, JsonElement data) { + this(timestamp, key, user, data, null); } } diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 13559cf97..fbf3bc171 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -42,8 +42,8 @@ public Event.FeatureRequest newDebugEvent(Event.FeatureRequest from) { from.defaultVal, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, from.reason, true); } - public Event.Custom newCustomEvent(String key, LDUser user, JsonElement data) { - return new Event.Custom(getTimestamp(), key, user, data); + public Event.Custom newCustomEvent(String key, LDUser user, JsonElement data, Double metricValue) { + return new Event.Custom(getTimestamp(), key, user, data, metricValue); } public Event.Identify newIdentifyEvent(LDUser user) { diff --git a/src/main/java/com/launchdarkly/client/EventOutput.java b/src/main/java/com/launchdarkly/client/EventOutput.java index 9cb4341e1..8ccd12403 100644 --- a/src/main/java/com/launchdarkly/client/EventOutput.java +++ b/src/main/java/com/launchdarkly/client/EventOutput.java @@ -80,13 +80,15 @@ static final class Custom extends EventOutputWithTimestamp { private final String userKey; private final LDUser user; private final JsonElement data; + private final Double metricValue; - Custom(long creationDate, String key, String userKey, LDUser user, JsonElement data) { + Custom(long creationDate, String key, String userKey, LDUser user, JsonElement data, Double metricValue) { super("custom", creationDate); this.key = key; this.userKey = userKey; this.user = user; this.data = data; + this.metricValue = metricValue; } } @@ -174,7 +176,8 @@ private EventOutput createOutputEvent(Event e) { return new EventOutput.Custom(ce.creationDate, ce.key, inlineUsers ? null : userKey, inlineUsers ? e.user : null, - ce.data); + ce.data, + ce.metricValue); } else if (e instanceof Event.Index) { return new EventOutput.Index(e.creationDate, e.user); } else { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index ca97cca0d..defaacc73 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -108,25 +108,28 @@ public LDClient(String sdkKey, LDConfig config) { public boolean initialized() { return updateProcessor.initialized(); } - + + @Override + public void track(String eventName, LDUser user) { + track(eventName, user, null); + } + @Override public void track(String eventName, LDUser user, JsonElement data) { - if (isOffline()) { - return; - } 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, null)); } } @Override - public void track(String eventName, LDUser user) { - if (isOffline()) { - return; + public void track(String eventName, LDUser user, JsonElement data, double metricValue) { + 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, metricValue)); } - track(eventName, user, null); } @Override diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 6385f474f..647a2386e 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -17,17 +17,27 @@ public interface LDClientInterface extends Closeable { * * @param eventName the name of the event * @param user the user that performed the event - * @param data a JSON object containing additional data associated with the event + */ + void track(String eventName, LDUser user); + + /** + * Tracks that a user performed an event, and provides additional custom data. + * + * @param eventName the name of the event + * @param user the user that performed the event + * @param data a JSON object containing additional data associated with the event; may be null */ void track(String eventName, LDUser user, JsonElement data); /** - * Tracks that a user performed an event. + * Tracks that a user performed an event, and provides an additional numeric value. * * @param eventName the name of the event * @param user the user that performed the event + * @param data a JSON object containing additional data associated with the event; may be null + * @param metricValue a numeric value that can be used for analytics purposes on the LaunchDarkly dashboard */ - void track(String eventName, LDUser user); + void track(String eventName, LDUser user, JsonElement data, double metricValue); /** * Registers the user. diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 03cba9c09..151debc34 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -338,7 +338,8 @@ public void customEventIsQueuedWithUser() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); - Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data); + double metric = 1.5; + Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, metric); ep.sendEvent(ce); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -355,7 +356,7 @@ public void customEventCanContainInlineUser() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); - Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data); + Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); ep.sendEvent(ce); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -369,7 +370,7 @@ public void userIsFilteredInCustomEvent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); - Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data); + Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); ep.sendEvent(ce); JsonArray output = flushAndGetEvents(new MockResponse()); @@ -548,6 +549,7 @@ private Matcher isFeatureEvent(Event.FeatureRequest sourceEvent, Fe ); } + @SuppressWarnings("unchecked") private Matcher isCustomEvent(Event.Custom sourceEvent, JsonElement inlineUser) { return allOf( hasJsonProperty("kind", "custom"), @@ -557,7 +559,9 @@ private Matcher isCustomEvent(Event.Custom sourceEvent, JsonElement hasJsonProperty("userKey", sourceEvent.user.getKeyAsString()), (inlineUser != null) ? hasJsonProperty("user", inlineUser) : hasJsonProperty("user", nullValue(JsonElement.class)), - hasJsonProperty("data", sourceEvent.data) + hasJsonProperty("data", sourceEvent.data), + (sourceEvent.metricValue == null) ? hasJsonProperty("metricValue", nullValue(JsonElement.class)) : + hasJsonProperty("metricValue", sourceEvent.metricValue.doubleValue()) ); } diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index f64ba29bd..29abebe73 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -40,7 +40,7 @@ public void summarizeEventDoesNothingForIdentifyEvent() { public void summarizeEventDoesNothingForCustomEvent() { EventSummarizer es = new EventSummarizer(); EventSummarizer.EventSummary snapshot = es.snapshot(); - es.summarizeEvent(eventFactory.newCustomEvent("whatever", user, null)); + es.summarizeEvent(eventFactory.newCustomEvent("whatever", user, null, null)); assertEquals(snapshot, es.snapshot()); } From 5a0313b8b4654ac1c5fe459d37a571e9f02ff93c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Sat, 13 Apr 2019 16:15:52 -0700 Subject: [PATCH 149/327] add test --- .../launchdarkly/client/LDClientEventTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index fead60c86..c3b6ff1fe 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -85,6 +85,23 @@ public void trackSendsEventWithData() throws Exception { assertEquals(data, ce.data); } + @Test + public void trackSendsEventWithDataAndMetricValue() throws Exception { + JsonObject data = new JsonObject(); + data.addProperty("thing", "stuff"); + double metricValue = 1.5; + client.track("eventkey", user, data, metricValue); + + assertEquals(1, eventSink.events.size()); + Event e = eventSink.events.get(0); + assertEquals(Event.Custom.class, e.getClass()); + Event.Custom ce = (Event.Custom)e; + assertEquals(user.getKey(), ce.user.getKey()); + assertEquals("eventkey", ce.key); + assertEquals(data, ce.data); + assertEquals(new Double(metricValue), ce.metricValue); + } + @Test public void trackWithNullUserDoesNotSendEvent() { client.track("eventkey", null); From 855e8c0337c6720983b587b57372c546de0881f8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 16 Apr 2019 12:58:48 -0700 Subject: [PATCH 150/327] update method description --- .../java/com/launchdarkly/client/LDClientInterface.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 647a2386e..a218f267e 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -30,12 +30,14 @@ public interface LDClientInterface extends Closeable { void track(String eventName, LDUser user, JsonElement data); /** - * Tracks that a user performed an event, and provides an additional numeric value. + * Tracks that a user performed an event, and provides an additional numeric value for custom metrics. * * @param eventName the name of the event * @param user the user that performed the event * @param data a JSON object containing additional data associated with the event; may be null - * @param metricValue a numeric value that can be used for analytics purposes on the LaunchDarkly dashboard + * @param metricValue a numeric value used by the LaunchDarkly experimentation feature in numeric custom + * metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be + * returned as part of the custom event for Data Export. */ void track(String eventName, LDUser user, JsonElement data, double metricValue); 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 151/327] 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 152/327] 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 153/327] 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 154/327] 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 155/327] 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 156/327] 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 157/327] 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 158/327] 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 159/327] 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"); From 275d4720def7aceb6584e1f0e4e3814532a8e3d6 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Thu, 25 Jul 2019 21:48:41 +0000 Subject: [PATCH 160/327] [ch39684] Diagnostic events (#130) --- .../client/DefaultEventProcessor.java | 283 ++++++++++++------ .../client/DiagnosticAccumulator.java | 20 ++ .../launchdarkly/client/DiagnosticEvent.java | 128 ++++++++ .../com/launchdarkly/client/DiagnosticId.java | 17 ++ .../launchdarkly/client/FeatureRequestor.java | 5 +- .../com/launchdarkly/client/LDClient.java | 32 +- .../com/launchdarkly/client/LDConfig.java | 116 ++++++- .../launchdarkly/client/StreamProcessor.java | 7 +- .../java/com/launchdarkly/client/Util.java | 25 +- .../client/DefaultEventProcessorTest.java | 191 ++++++++++-- .../client/DiagnosticAccumulatorTest.java | 52 ++++ .../launchdarkly/client/DiagnosticIdTest.java | 52 ++++ .../client/DiagnosticSdkTest.java | 60 ++++ .../client/DiagnosticStatisticsEventTest.java | 46 +++ .../com/launchdarkly/client/LDConfigTest.java | 48 +++ .../client/StreamProcessorTest.java | 10 + .../com/launchdarkly/client/TestUtil.java | 2 +- 17 files changed, 936 insertions(+), 158 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java create mode 100644 src/main/java/com/launchdarkly/client/DiagnosticEvent.java create mode 100644 src/main/java/com/launchdarkly/client/DiagnosticId.java create mode 100644 src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java create mode 100644 src/test/java/com/launchdarkly/client/DiagnosticIdTest.java create mode 100644 src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java create mode 100644 src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 5afb3fdec..a21c1b197 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -4,6 +4,7 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.client.EventSummarizer.EventSummary; +import okhttp3.Headers; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,6 +17,7 @@ import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.Semaphore; import java.util.concurrent.ThreadFactory; @@ -24,25 +26,25 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; -import static com.launchdarkly.client.Util.getRequestBuilder; -import static com.launchdarkly.client.Util.httpErrorMessage; -import static com.launchdarkly.client.Util.isHttpErrorRecoverable; - import okhttp3.MediaType; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; +import static com.launchdarkly.client.Util.getHeadersBuilderFor; +import static com.launchdarkly.client.Util.httpErrorMessage; +import static com.launchdarkly.client.Util.isHttpErrorRecoverable; + final class DefaultEventProcessor implements EventProcessor { private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; private static final String EVENT_SCHEMA_VERSION = "3"; - + private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; private final AtomicBoolean closed = new AtomicBoolean(false); private volatile boolean inputCapacityExceeded = false; - + DefaultEventProcessor(String sdkKey, LDConfig config) { inbox = new ArrayBlockingQueue<>(config.capacity); @@ -68,15 +70,23 @@ public void run() { }; this.scheduler.scheduleAtFixedRate(userKeysFlusher, config.userKeysFlushInterval, config.userKeysFlushInterval, TimeUnit.SECONDS); + if (!config.diagnosticOptOut) { + Runnable diagnosticsTrigger = new Runnable() { + public void run() { + postMessageAsync(MessageType.DIAGNOSTIC, null); + } + }; + this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, config.diagnosticRecordingIntervalMillis, config.diagnosticRecordingIntervalMillis, TimeUnit.MILLISECONDS); + } } - + @Override public void sendEvent(Event e) { if (!closed.get()) { postMessageAsync(MessageType.EVENT, e); } } - + @Override public void flush() { if (!closed.get()) { @@ -92,23 +102,28 @@ public void close() throws IOException { postMessageAndWait(MessageType.SHUTDOWN, null); } } - + @VisibleForTesting void waitUntilInactive() throws IOException { postMessageAndWait(MessageType.SYNC, null); } - + + @VisibleForTesting + void postDiagnostic() { + postMessageAsync(MessageType.DIAGNOSTIC, null); + } + private void postMessageAsync(MessageType type, Event event) { postToChannel(new EventProcessorMessage(type, event, false)); } - + private void postMessageAndWait(MessageType type, Event event) { EventProcessorMessage message = new EventProcessorMessage(type, event, true); if (postToChannel(message)) { message.waitForCompletion(); } } - + private boolean postToChannel(EventProcessorMessage message) { if (inbox.offer(message)) { return true; @@ -129,27 +144,28 @@ private static enum MessageType { EVENT, FLUSH, FLUSH_USERS, + DIAGNOSTIC, SYNC, SHUTDOWN } - + private static final class EventProcessorMessage { private final MessageType type; private final Event event; private final Semaphore reply; - + private EventProcessorMessage(MessageType type, Event event, boolean sync) { this.type = type; this.event = event; reply = sync ? new Semaphore(0) : null; } - + void completed() { if (reply != null) { reply.release(); } } - + void waitForCompletion() { if (reply == null) { return; @@ -163,14 +179,14 @@ void waitForCompletion() { } } } - + @Override public String toString() { // for debugging only return ((event == null) ? type.toString() : (type + ": " + event.getClass().getSimpleName())) + (reply == null ? "" : " (sync)"); } } - + /** * Takes messages from the input queue, updating the event buffer and summary counters * on its own thread. @@ -179,13 +195,17 @@ 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; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; private final Random random = new Random(); private final AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); + private final ExecutorService diagnosticExecutor; + private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; + + private long deduplicatedUsers = 0; private EventDispatcher(String sdkKey, LDConfig config, final BlockingQueue inbox, @@ -198,17 +218,17 @@ private EventDispatcher(String sdkKey, LDConfig config, // picked up by any worker, so if we try to push another one and are refused, it means // all the workers are busy. final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); - + final EventBuffer outbox = new EventBuffer(config.capacity); final SimpleLRUCache userKeys = new SimpleLRUCache(config.userKeysCapacity); - + Thread mainThread = threadFactory.newThread(new Runnable() { public void run() { runMainLoop(inbox, outbox, userKeys, payloadQueue); } }); 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 @@ -226,9 +246,9 @@ public void uncaughtException(Thread t, Throwable e) { } } }); - + mainThread.start(); - + flushWorkers = new ArrayList<>(); EventResponseListener listener = new EventResponseListener() { public void handleResponse(Response response) { @@ -240,8 +260,22 @@ public void handleResponse(Response response) { busyFlushWorkersCount, threadFactory); flushWorkers.add(task); } + + if (!config.diagnosticOptOut) { + // Set up diagnostics + long currentTime = System.currentTimeMillis(); + DiagnosticId diagnosticId = new DiagnosticId(sdkKey); + config.diagnosticAccumulator.start(diagnosticId, currentTime); + this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(sdkKey, config); + diagnosticExecutor = Executors.newSingleThreadExecutor(threadFactory); + DiagnosticEvent.Init diagnosticInitEvent = new DiagnosticEvent.Init(currentTime, diagnosticId, config); + diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); + } else { + diagnosticExecutor = null; + sendDiagnosticTaskFactory = null; + } } - + /** * This task drains the input queue as quickly as possible. Everything here is done on a single * thread so we don't have to synchronize on our internal structures; when it's time to flush, @@ -257,7 +291,7 @@ private void runMainLoop(BlockingQueue inbox, 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) { + switch (message.type) { case EVENT: processEvent(message.event, userKeys, outbox); break; @@ -267,6 +301,9 @@ private void runMainLoop(BlockingQueue inbox, case FLUSH_USERS: userKeys.clear(); break; + case DIAGNOSTIC: + sendAndResetDiagnostics(outbox); + break; case SYNC: // this is used only by unit tests waitUntilAllFlushWorkersInactive(); break; @@ -284,13 +321,24 @@ private void runMainLoop(BlockingQueue inbox, } } } - + + private void sendAndResetDiagnostics(EventBuffer outbox) { + long droppedEvents = outbox.getAndClearDroppedCount(); + long eventsInQueue = outbox.getEventsInQueueCount(); + DiagnosticEvent diagnosticEvent = config.diagnosticAccumulator.createEventAndReset(droppedEvents, deduplicatedUsers, eventsInQueue); + deduplicatedUsers = 0; + diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticEvent)); + } + private void doShutdown() { waitUntilAllFlushWorkersInactive(); disabled.set(true); // In case there are any more messages, we want to ignore them for (SendEventsTask task: flushWorkers) { task.stop(); } + if (diagnosticExecutor != null) { + diagnosticExecutor.shutdown(); + } // Note that we don't close the HTTP client here, because it's shared by other components // via the LDConfig. The LDClient will dispose of it. } @@ -308,7 +356,7 @@ private void waitUntilAllFlushWorkersInactive() { } catch (InterruptedException e) {} } } - + private void processEvent(Event e, SimpleLRUCache userKeys, EventBuffer outbox) { if (disabled.get()) { return; @@ -322,7 +370,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even boolean addIndexEvent = false, addFullEvent = false; Event debugEvent = null; - + if (e instanceof Event.FeatureRequest) { if (shouldSampleEvent()) { Event.FeatureRequest fe = (Event.FeatureRequest)e; @@ -334,17 +382,20 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even } else { addFullEvent = shouldSampleEvent(); } - + // For each user we haven't seen before, we add an index event - unless this is already // an identify event for that user. if (!addFullEvent || !config.inlineUsersInEvents) { - if (e.user != null && e.user.getKey() != null && !noticeUser(e.user, userKeys)) { - if (!(e instanceof Event.Identify)) { - addIndexEvent = true; - } + if (e.user != null && e.user.getKey() != null) { + boolean isIndexEvent = e instanceof Event.Identify; + boolean alreadySeen = noticeUser(e.user, userKeys); + addIndexEvent = !isIndexEvent & !alreadySeen; + if (!isIndexEvent & alreadySeen) { + deduplicatedUsers++; + } } } - + if (addIndexEvent) { Event.Index ie = new Event.Index(e.creationDate, e.user); outbox.add(ie); @@ -356,7 +407,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even outbox.add(debugEvent); } } - + // Add to the set of users we've noticed, and return true if the user was already known to us. private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) { if (user == null || user.getKey() == null) { @@ -365,11 +416,11 @@ private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) String key = user.getKeyAsString(); return userKeys.put(key, key) != null; } - + private boolean shouldSampleEvent() { return config.samplingInterval <= 0 || random.nextInt(config.samplingInterval) == 0; } - + private boolean shouldDebugEvent(Event.FeatureRequest fe) { if (fe.debugEventsUntilDate != null) { // The "last known past time" comes from the last HTTP response we got from the server. @@ -382,9 +433,9 @@ private boolean shouldDebugEvent(Event.FeatureRequest fe) { return true; } } - return false; + return false; } - + private void triggerFlush(EventBuffer outbox, BlockingQueue payloadQueue) { if (disabled.get() || outbox.isEmpty()) { return; @@ -403,7 +454,7 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } } } - + private void handleResponse(Response response) { String dateStr = response.header("Date"); if (dateStr != null) { @@ -421,65 +472,116 @@ private void handleResponse(Response response) { } } } - + private static final class EventBuffer { final List events = new ArrayList<>(); final EventSummarizer summarizer = new EventSummarizer(); private final int capacity; private boolean capacityExceeded = false; - + private long droppedEventCount = 0; + EventBuffer(int capacity) { this.capacity = capacity; } - + void add(Event e) { if (events.size() >= capacity) { if (!capacityExceeded) { // don't need AtomicBoolean, this is only checked on one thread capacityExceeded = true; logger.warn("Exceeded event queue capacity. Increase capacity to avoid dropping events."); } + droppedEventCount++; } else { capacityExceeded = false; events.add(e); } } - + void addToSummary(Event e) { summarizer.summarizeEvent(e); } - + boolean isEmpty() { return events.isEmpty() && summarizer.snapshot().isEmpty(); } - + + long getAndClearDroppedCount() { + long res = droppedEventCount; + droppedEventCount = 0; + return res; + } + + long getEventsInQueueCount() { + return events.size(); + } + FlushPayload getPayload() { Event[] eventsOut = events.toArray(new Event[events.size()]); EventSummarizer.EventSummary summary = summarizer.snapshot(); return new FlushPayload(eventsOut, summary); } - + void clear() { events.clear(); summarizer.clear(); } } - + private static final class FlushPayload { final Event[] events; final EventSummary summary; - + FlushPayload(Event[] events, EventSummary summary) { this.events = events; this.summary = summary; } } - + private static interface EventResponseListener { void handleResponse(Response response); } - + + private static void postJson(LDConfig config, Headers headers, String json, String uriStr, String descriptor, + EventResponseListener responseListener) { + logger.debug("Posting {} to {} with payload: {}", descriptor, uriStr, json); + + for (int attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) { + logger.warn("Will retry posting {} after 1 second", descriptor); + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + } + } + + Request request = new Request.Builder() + .url(uriStr) + .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json)) + .headers(headers) + .build(); + + long startTime = System.currentTimeMillis(); + try (Response response = config.httpClient.newCall(request).execute()) { + long endTime = System.currentTimeMillis(); + logger.debug("{} delivery took {} ms, response status {}", descriptor, endTime - startTime, response.code()); + if (!response.isSuccessful()) { + logger.warn("Unexpected response status when posting {}: {}", descriptor, response.code()); + if (isHttpErrorRecoverable(response.code())) { + continue; + } + } + if (responseListener != null) { + responseListener.handleResponse(response); + } + break; + } catch (IOException e) { + logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: " + request.url(), e); + continue; + } + } + } + private static final class SendEventsTask implements Runnable { - private final String sdkKey; private final LDConfig config; private final EventResponseListener responseListener; private final BlockingQueue payloadQueue; @@ -487,22 +589,28 @@ private static final class SendEventsTask implements Runnable { private final AtomicBoolean stopping; private final EventOutput.Formatter formatter; private final Thread thread; - + private final String uriStr; + private final Headers headers; + SendEventsTask(String sdkKey, LDConfig config, EventResponseListener responseListener, BlockingQueue payloadQueue, AtomicInteger activeFlushWorkersCount, ThreadFactory threadFactory) { - this.sdkKey = sdkKey; this.config = config; this.formatter = new EventOutput.Formatter(config.inlineUsersInEvents); this.responseListener = responseListener; this.payloadQueue = payloadQueue; this.activeFlushWorkersCount = activeFlushWorkersCount; this.stopping = new AtomicBoolean(false); + this.uriStr = config.eventsURI.toString() + "/bulk"; + this.headers = getHeadersBuilderFor(sdkKey, config) + .add("Content-Type", "application/json") + .add(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION) + .build(); thread = threadFactory.newThread(this); thread.setDaemon(true); thread.start(); } - + public void run() { while (!stopping.get()) { FlushPayload payload = null; @@ -526,50 +634,39 @@ public void run() { } } } - + void stop() { stopping.set(true); thread.interrupt(); } - + private void postEvents(List eventsOut) { String json = config.gson.toJson(eventsOut); - String uriStr = config.eventsURI.toString() + "/bulk"; - - logger.debug("Posting {} event(s) to {} with payload: {}", - eventsOut.size(), uriStr, json); - - for (int attempt = 0; attempt < 2; attempt++) { - if (attempt > 0) { - logger.warn("Will retry posting events after 1 second"); - try { - Thread.sleep(1000); - } catch (InterruptedException e) {} - } - Request request = getRequestBuilder(sdkKey) - .url(uriStr) - .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json)) - .addHeader("Content-Type", "application/json") - .addHeader(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION) - .build(); - - long startTime = System.currentTimeMillis(); - try (Response response = config.httpClient.newCall(request).execute()) { - long endTime = System.currentTimeMillis(); - logger.debug("Event delivery took {} ms, response status {}", endTime - startTime, response.code()); - if (!response.isSuccessful()) { - logger.warn("Unexpected response status when posting events: {}", response.code()); - if (isHttpErrorRecoverable(response.code())) { - continue; - } - } - responseListener.handleResponse(response); - break; - } catch (IOException e) { - logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: " + request.url(), e); - continue; + postJson(config, headers, json, uriStr, String.format("%d event(s)", eventsOut.size()), responseListener); + } + } + + private static final class SendDiagnosticTaskFactory { + private final LDConfig config; + private final String uriStr; + private final Headers headers; + + SendDiagnosticTaskFactory(String sdkKey, LDConfig config) { + this.config = config; + this.uriStr = config.eventsURI.toString() + "/diagnostic"; + this.headers = getHeadersBuilderFor(sdkKey, config) + .add("Content-Type", "application/json") + .build(); + } + + Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { + return new Runnable() { + @Override + public void run() { + String json = config.gson.toJson(diagnosticEvent); + postJson(config, headers, json, uriStr, "diagnostic event", null); } - } + }; } } } diff --git a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java new file mode 100644 index 000000000..1ee9b33a0 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java @@ -0,0 +1,20 @@ +package com.launchdarkly.client; + +class DiagnosticAccumulator { + + volatile long dataSinceDate; + volatile DiagnosticId diagnosticId; + + void start(DiagnosticId diagnosticId, long dataSinceDate) { + this.diagnosticId = diagnosticId; + this.dataSinceDate = dataSinceDate; + } + + DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long deduplicatedUsers, long eventsInQueue) { + long currentTime = System.currentTimeMillis(); + DiagnosticEvent.Statistics res = new DiagnosticEvent.Statistics(currentTime, diagnosticId, dataSinceDate, droppedEvents, + deduplicatedUsers, eventsInQueue); + dataSinceDate = currentTime; + return res; + } +} diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java new file mode 100644 index 000000000..36be09c9b --- /dev/null +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -0,0 +1,128 @@ +package com.launchdarkly.client; + +import java.net.URI; + +class DiagnosticEvent { + + final String kind; + final long creationDate; + final DiagnosticId id; + + DiagnosticEvent(String kind, long creationDate, DiagnosticId id) { + this.kind = kind; + this.creationDate = creationDate; + this.id = id; + } + + static class Statistics extends DiagnosticEvent { + + final long dataSinceDate; + final long droppedEvents; + final long deduplicatedUsers; + final long eventsInQueue; + + Statistics(long creationDate, DiagnosticId id, long dataSinceDate, long droppedEvents, long deduplicatedUsers, + long eventsInQueue) { + super("diagnostic", creationDate, id); + this.dataSinceDate = dataSinceDate; + this.droppedEvents = droppedEvents; + this.deduplicatedUsers = deduplicatedUsers; + this.eventsInQueue = eventsInQueue; + } + } + + static class Init extends DiagnosticEvent { + + final DiagnosticSdk sdk; + final DiagnosticConfiguration configuration; + final DiagnosticPlatform platform = new DiagnosticPlatform(); + + Init(long creationDate, DiagnosticId diagnosticId, LDConfig config) { + super("diagnostic-init", creationDate, diagnosticId); + this.sdk = new DiagnosticSdk(config); + this.configuration = new DiagnosticConfiguration(config); + } + + static class DiagnosticConfiguration { + private final URI baseURI; + private final URI eventsURI; + private final URI streamURI; + private final int eventsCapacity; + private final int connectTimeoutMillis; + private final int socketTimeoutMillis; + private final long eventsFlushIntervalMillis; + private final boolean usingProxy; + private final boolean usingProxyAuthenticator; + private final boolean streamingDisabled; + private final boolean usingRelayDaemon; + private final boolean offline; + private final boolean allAttributesPrivate; + private final boolean eventReportingDisabled; + private final long pollingIntervalMillis; + private final long startWaitMillis; + private final int samplingInterval; + private final long reconnectTimeMillis; + private final int userKeysCapacity; + private final long userKeysFlushIntervalMillis; + private final boolean inlineUsersInEvents; + private final int diagnosticRecordingIntervalMillis; + private final String featureStore; + + DiagnosticConfiguration(LDConfig config) { + this.baseURI = config.baseURI; + this.eventsURI = config.eventsURI; + this.streamURI = config.streamURI; + this.eventsCapacity = config.capacity; + this.connectTimeoutMillis = config.connectTimeoutMillis; + this.socketTimeoutMillis = config.socketTimeoutMillis; + this.eventsFlushIntervalMillis = config.flushInterval * 1000; + this.usingProxy = config.proxy != null; + this.usingProxyAuthenticator = config.proxyAuthenticator != null; + this.streamingDisabled = !config.stream; + this.usingRelayDaemon = config.useLdd; + this.offline = config.offline; + this.allAttributesPrivate = config.allAttributesPrivate; + this.eventReportingDisabled = !config.sendEvents; + this.pollingIntervalMillis = config.pollingIntervalMillis; + this.startWaitMillis = config.startWaitMillis; + this.samplingInterval = config.samplingInterval; + this.reconnectTimeMillis = config.reconnectTimeMs; + this.userKeysCapacity = config.userKeysCapacity; + this.userKeysFlushIntervalMillis = config.userKeysFlushInterval * 1000; + this.inlineUsersInEvents = config.inlineUsersInEvents; + this.diagnosticRecordingIntervalMillis = config.diagnosticRecordingIntervalMillis; + if (config.deprecatedFeatureStore != null) { + this.featureStore = config.deprecatedFeatureStore.getClass().getSimpleName(); + } else if (config.featureStoreFactory != null) { + this.featureStore = config.featureStoreFactory.getClass().getSimpleName(); + } else { + this.featureStore = null; + } + } + } + + static class DiagnosticSdk { + final String name = "java-server-sdk"; + final String version = LDClient.CLIENT_VERSION; + final String wrapperName; + final String wrapperVersion; + + DiagnosticSdk(LDConfig config) { + this.wrapperName = config.wrapperName; + this.wrapperVersion = config.wrapperVersion; + } + } + + static class DiagnosticPlatform { + private final String name = "Java"; + private final String javaVendor = System.getProperty("java.vendor"); + private final String javaVersion = System.getProperty("java.version"); + private final String osArch = System.getProperty("os.arch"); + private final String osName = System.getProperty("os.name"); + private final String osVersion = System.getProperty("os.version"); + + DiagnosticPlatform() { + } + } + } +} diff --git a/src/main/java/com/launchdarkly/client/DiagnosticId.java b/src/main/java/com/launchdarkly/client/DiagnosticId.java new file mode 100644 index 000000000..713aebe33 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/DiagnosticId.java @@ -0,0 +1,17 @@ +package com.launchdarkly.client; + +import java.util.UUID; + +class DiagnosticId { + + final String diagnosticId = UUID.randomUUID().toString(); + final String sdkKeySuffix; + + DiagnosticId(String sdkKey) { + if (sdkKey == null) { + sdkKeySuffix = null; + } else { + this.sdkKeySuffix = sdkKey.substring(Math.max(0, sdkKey.length() - 6)); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index 180270295..6f764ffe0 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -7,7 +7,7 @@ import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.Util.getRequestBuilder; +import static com.launchdarkly.client.Util.getHeadersBuilderFor; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; @@ -70,8 +70,9 @@ AllData getAllData() throws IOException, HttpErrorException { } private String get(String path) throws IOException, HttpErrorException { - Request request = getRequestBuilder(sdkKey) + Request request = new Request.Builder() .url(config.baseURI.toString() + path) + .headers(getHeadersBuilderFor(sdkKey, config).build()) .get() .build(); diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index ca97cca0d..1b23fcbe0 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -58,12 +58,12 @@ public LDClient(String sdkKey) { * @param config a client configuration object */ public LDClient(String sdkKey, LDConfig config) { - this.config = config; + this.config = new LDConfig(config); this.sdkKey = sdkKey; - + FeatureStore store; - if (config.deprecatedFeatureStore != null) { - store = config.deprecatedFeatureStore; + if (this.config.deprecatedFeatureStore != null) { + store = this.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 @@ -71,27 +71,27 @@ public LDClient(String sdkKey, LDConfig config) { // of instances that we created ourselves from a factory. this.shouldCloseFeatureStore = false; } else { - FeatureStoreFactory factory = config.featureStoreFactory == null ? - Components.inMemoryFeatureStore() : config.featureStoreFactory; + FeatureStoreFactory factory = this.config.featureStoreFactory == null ? + Components.inMemoryFeatureStore() : this.config.featureStoreFactory; store = factory.createFeatureStore(); this.shouldCloseFeatureStore = true; } this.featureStore = new FeatureStoreClientWrapper(store); - EventProcessorFactory epFactory = config.eventProcessorFactory == null ? - Components.defaultEventProcessor() : config.eventProcessorFactory; - this.eventProcessor = epFactory.createEventProcessor(sdkKey, config); + EventProcessorFactory epFactory = this.config.eventProcessorFactory == null ? + Components.defaultEventProcessor() : this.config.eventProcessorFactory; + this.eventProcessor = epFactory.createEventProcessor(sdkKey, this.config); - UpdateProcessorFactory upFactory = config.updateProcessorFactory == null ? - Components.defaultUpdateProcessor() : config.updateProcessorFactory; - this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, config, featureStore); + UpdateProcessorFactory upFactory = this.config.updateProcessorFactory == null ? + Components.defaultUpdateProcessor() : this.config.updateProcessorFactory; + this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, this.config, featureStore); Future startFuture = updateProcessor.start(); - if (config.startWaitMillis > 0L) { - if (!config.offline && !config.useLdd) { - logger.info("Waiting up to " + config.startWaitMillis + " milliseconds for LaunchDarkly client to start..."); + if (this.config.startWaitMillis > 0L) { + if (!this.config.offline && !this.config.useLdd) { + logger.info("Waiting up to " + this.config.startWaitMillis + " milliseconds for LaunchDarkly client to start..."); } try { - startFuture.get(config.startWaitMillis, TimeUnit.MILLISECONDS); + startFuture.get(this.config.startWaitMillis, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { logger.error("Timeout encountered waiting for LaunchDarkly client initialization"); } catch (Exception e) { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 938761b48..f53fcd871 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -45,6 +45,8 @@ public final class LDConfig { private static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; private static final long DEFAULT_RECONNECT_TIME_MILLIS = 1000; private static final long MAX_HTTP_CACHE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + private static final int DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS = 900_000; // 15 minutes + private static final int MIN_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS = 60_000; // 1 minute protected static final LDConfig DEFAULT = new Builder().build(); @@ -75,7 +77,13 @@ public final class LDConfig { final int userKeysCapacity; final int userKeysFlushInterval; final boolean inlineUsersInEvents; - + final int diagnosticRecordingIntervalMillis; + final boolean diagnosticOptOut; + final String wrapperName; + final String wrapperVersion; + + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); + protected LDConfig(Builder builder) { this.baseURI = builder.baseURI; this.eventsURI = builder.eventsURI; @@ -107,6 +115,14 @@ protected LDConfig(Builder builder) { this.userKeysCapacity = builder.userKeysCapacity; this.userKeysFlushInterval = builder.userKeysFlushInterval; this.inlineUsersInEvents = builder.inlineUsersInEvents; + if (builder.diagnosticRecordingIntervalMillis < MIN_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS) { + this.diagnosticRecordingIntervalMillis = MIN_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS; + } else { + this.diagnosticRecordingIntervalMillis = builder.diagnosticRecordingIntervalMillis; + } + this.diagnosticOptOut = builder.diagnosticOptOut; + this.wrapperName = builder.wrapperName; + this.wrapperVersion = builder.wrapperVersion; OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder() .connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) @@ -138,6 +154,41 @@ protected LDConfig(Builder builder) { .build(); } + LDConfig(LDConfig config) { + this.baseURI = config.baseURI; + this.eventsURI = config.eventsURI; + this.streamURI = config.streamURI; + this.capacity = config.capacity; + this.connectTimeoutMillis = config.connectTimeoutMillis; + this.socketTimeoutMillis = config.socketTimeoutMillis; + this.flushInterval = config.flushInterval; + this.proxy = config.proxy; + this.proxyAuthenticator = config.proxyAuthenticator; + this.httpClient = config.httpClient; + this.stream = config.stream; + this.deprecatedFeatureStore = config.deprecatedFeatureStore; + this.featureStoreFactory = config.featureStoreFactory; + this.eventProcessorFactory = config.eventProcessorFactory; + this.updateProcessorFactory = config.updateProcessorFactory; + this.useLdd = config.useLdd; + this.offline = config.offline; + this.allAttributesPrivate = config.allAttributesPrivate; + this.privateAttrNames = config.privateAttrNames; + this.sendEvents = config.sendEvents; + this.pollingIntervalMillis = config.pollingIntervalMillis; + this.startWaitMillis = config.startWaitMillis; + this.samplingInterval = config.samplingInterval; + this.reconnectTimeMs = config.reconnectTimeMs; + this.userKeysCapacity = config.userKeysCapacity; + this.userKeysFlushInterval = config.userKeysFlushInterval; + this.inlineUsersInEvents = config.inlineUsersInEvents; + this.diagnosticRecordingIntervalMillis = config.diagnosticRecordingIntervalMillis; + this.diagnosticOptOut = config.diagnosticOptOut; + this.wrapperName = config.wrapperName; + this.wrapperVersion = config.wrapperVersion; + this.diagnosticAccumulator = new DiagnosticAccumulator(); + } + /** * A builder that helps construct {@link com.launchdarkly.client.LDConfig} objects. Builder * calls can be chained, enabling the following pattern: @@ -177,6 +228,10 @@ public static class Builder { private int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; private int userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; private boolean inlineUsersInEvents = false; + private int diagnosticRecordingIntervalMillis = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS; + private boolean diagnosticOptOut = false; + private String wrapperName = null; + private String wrapperVersion = null; /** * Creates a builder with all configuration parameters set to the default @@ -556,7 +611,64 @@ public Builder inlineUsersInEvents(boolean inlineUsersInEvents) { this.inlineUsersInEvents = inlineUsersInEvents; return this; } - + + /** + * Sets the interval at which periodic diagnostic data is sent. The default is every 15 minutes (900,000 + * milliseconds) and the minimum value is 6000. + * + * @see this.diagnosticOptOut for more information on the diagnostics data being sent. + * + * @param diagnosticRecordingIntervalMillis the diagnostics interval in milliseconds + * @return the builder + */ + public Builder diagnosticRecordingIntervalMillis(int diagnosticRecordingIntervalMillis) { + this.diagnosticRecordingIntervalMillis = diagnosticRecordingIntervalMillis; + return this; + } + + /** + * Set to true to opt out of sending diagnostics data. + * + * Unless the diagnosticOptOut field is set to true, the client will send some diagnostics data to the + * LaunchDarkly servers in order to assist in the development of future SDK improvements. These diagnostics + * consist of an initial payload containing some details of SDK in use, the SDK's configuration, and the platform + * the SDK is being run on; as well as payloads sent periodically with information on irregular occurrences such + * as dropped events. + * + * @param diagnosticOptOut true if you want to opt out of sending any diagnostics data. + * @return the builder + */ + public Builder diagnosticOptOut(boolean diagnosticOptOut) { + this.diagnosticOptOut = diagnosticOptOut; + return this; + } + + /** + * For use by wrapper libraries to set an identifying name for the wrapper being used. This will be sent in + * User-Agent headers during requests to the LaunchDarkly servers to allow recording metrics on the usage of + * these wrapper libraries. + * + * @param wrapperName An identifying name for the wrapper library + * @return the builder + */ + public Builder wrapperName(String wrapperName) { + this.wrapperName = wrapperName; + return this; + } + + /** + * For use by wrapper libraries to report the version of the library in use. If {@link this.wrappeName} is not + * set, this field will be ignored. Otherwise the version string will be included in the User-Agent headers along + * with the wrapperName during requests to the LaunchDarkly servers. + * + * @param wrapperVersion Version string for the wrapper library + * @return the builder + */ + public Builder wrapperVersion(String wrapperVersion) { + this.wrapperVersion = wrapperVersion; + return this; + } + // returns null if none of the proxy bits were configured. Minimum required part: port. Proxy proxy() { if (this.proxyPort == -1) { diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index f5fcb621a..797130f9a 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -19,6 +19,7 @@ import static com.launchdarkly.client.Util.httpErrorMessage; import static com.launchdarkly.client.Util.isHttpErrorRecoverable; +import static com.launchdarkly.client.Util.getHeadersBuilderFor; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; @@ -58,9 +59,7 @@ public static interface EventSourceCreator { public Future start() { final SettableFuture initFuture = SettableFuture.create(); - Headers headers = new Headers.Builder() - .add("Authorization", this.sdkKey) - .add("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION) + Headers headers = getHeadersBuilderFor(sdkKey, config) .add("Accept", "text/event-stream") .build(); @@ -78,7 +77,7 @@ public Action onConnectionError(Throwable t) { return Action.PROCEED; } }; - + EventHandler handler = new EventHandler() { @Override diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 78ea7b759..b7610bc82 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -1,15 +1,14 @@ package com.launchdarkly.client; import com.google.gson.JsonPrimitive; +import okhttp3.Headers; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; -import okhttp3.Request; - class Util { /** * Converts either a unix epoch millis number or RFC3339/ISO8601 timestamp as {@link JsonPrimitive} to a {@link DateTime} object. - * @param maybeDate wraps either a nubmer or a string that may contain a valid timestamp. + * @param maybeDate wraps either a number or a string that may contain a valid timestamp. * @return null if input is not a valid format. */ static DateTime jsonPrimitiveToDateTime(JsonPrimitive maybeDate) { @@ -26,11 +25,21 @@ static DateTime jsonPrimitiveToDateTime(JsonPrimitive maybeDate) { return null; } } - - static Request.Builder getRequestBuilder(String sdkKey) { - return new Request.Builder() - .addHeader("Authorization", sdkKey) - .addHeader("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION); + + static Headers.Builder getHeadersBuilderFor(String sdkKey, LDConfig config) { + Headers.Builder builder = new Headers.Builder() + .add("Authorization", sdkKey) + .add("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION); + + if (config.wrapperName != null) { + String wrapperVersion = ""; + if (config.wrapperVersion != null) { + wrapperVersion = "/" + config.wrapperVersion; + } + builder.add("X-LaunchDarkly-Wrapper", config.wrapperName + wrapperVersion); + } + + return builder; } /** diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 03cba9c09..d1230ccb8 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -20,13 +20,14 @@ import static com.launchdarkly.client.TestUtil.simpleEvaluation; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -41,7 +42,8 @@ public class DefaultEventProcessorTest { private static final JsonElement filteredUserJson = gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); - private final LDConfig.Builder configBuilder = new LDConfig.Builder(); + private final LDConfig.Builder configBuilder = new LDConfig.Builder().diagnosticOptOut(true); + private final LDConfig.Builder diagConfigBuilder = new LDConfig.Builder(); private final MockWebServer server = new MockWebServer(); private DefaultEventProcessor ep; @@ -49,6 +51,7 @@ public class DefaultEventProcessorTest { public void setup() throws Exception { server.start(); configBuilder.eventsURI(server.url("/").uri()); + diagConfigBuilder.eventsURI(server.url("/").uri()); } @After @@ -59,18 +62,16 @@ public void teardown() throws Exception { server.shutdown(); } - @SuppressWarnings("unchecked") @Test public void identifyEventIsQueued() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); - + JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isIdentifyEvent(e, userJson))); + assertThat(output, contains(isIdentifyEvent(e, userJson))); } - @SuppressWarnings("unchecked") @Test public void userIsFilteredInIdentifyEvent() throws Exception { configBuilder.allAttributesPrivate(true); @@ -79,7 +80,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { ep.sendEvent(e); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isIdentifyEvent(e, filteredUserJson))); + assertThat(output, contains(isIdentifyEvent(e, filteredUserJson))); } @SuppressWarnings("unchecked") @@ -92,7 +93,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, userJson), isFeatureEvent(fe, flag, false, null), isSummaryEvent() @@ -110,7 +111,7 @@ public void userIsFilteredInIndexEvent() throws Exception { ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, filteredUserJson), isFeatureEvent(fe, flag, false, null), isSummaryEvent() @@ -128,7 +129,7 @@ public void featureEventCanContainInlineUser() throws Exception { ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isFeatureEvent(fe, flag, false, userJson), isSummaryEvent() )); @@ -145,7 +146,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isFeatureEvent(fe, flag, false, filteredUserJson), isSummaryEvent() )); @@ -162,7 +163,7 @@ public void featureEventCanContainReason() throws Exception { ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, userJson), isFeatureEvent(fe, flag, false, null, reason), isSummaryEvent() @@ -180,7 +181,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, userJson), isSummaryEvent() )); @@ -197,7 +198,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, userJson), isFeatureEvent(fe, flag, true, userJson), isSummaryEvent() @@ -216,7 +217,7 @@ public void eventCanBeBothTrackedAndDebugged() throws Exception { ep.sendEvent(fe); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, userJson), isFeatureEvent(fe, flag, false, null), isFeatureEvent(fe, flag, true, userJson), @@ -246,7 +247,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() // Should get a summary event only, not a full feature event JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, userJson), isSummaryEvent(fe.creationDate, fe.creationDate) )); @@ -274,7 +275,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() // Should get a summary event only, not a full feature event JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe, userJson), isSummaryEvent(fe.creationDate, fe.creationDate) )); @@ -295,14 +296,40 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except ep.sendEvent(fe2); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(fe1, userJson), isFeatureEvent(fe1, flag1, false, null), isFeatureEvent(fe2, flag2, false, null), - isSummaryEvent(fe1.creationDate, fe2.creationDate) + allOf( + isSummaryEvent(fe1.creationDate, fe2.creationDate), + hasSummaryFlag(flag1.getKey(), null, + contains(isSummaryEventCounter(flag1, 1, value, 1))), + hasSummaryFlag(flag2.getKey(), null, + contains(isSummaryEventCounter(flag2, 1, value, 1)) + ) + ) )); } - + + @SuppressWarnings("unchecked") + @Test + public void identifyEventMakesIndexEventUnnecessary() throws Exception { + ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); + Event ie = EventFactory.DEFAULT.newIdentifyEvent(user); + ep.sendEvent(ie); + FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); + Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, + simpleEvaluation(1, new JsonPrimitive("value")), null); + ep.sendEvent(fe); + + JsonArray output = flushAndGetEvents(new MockResponse()); + assertThat(output, hasItems( + isIdentifyEvent(ie, userJson), + isFeatureEvent(fe, flag, false, null), + isSummaryEvent() + )); + } + @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { @@ -325,13 +352,13 @@ public void nonTrackedEventsAreSummarized() throws Exception { allOf( isSummaryEvent(fe1.creationDate, fe2.creationDate), hasSummaryFlag(flag1.getKey(), default1, - hasItem(isSummaryEventCounter(flag1, 2, value, 1))), + contains(isSummaryEventCounter(flag1, 2, value, 1))), hasSummaryFlag(flag2.getKey(), default2, - hasItem(isSummaryEventCounter(flag2, 2, value, 1))) + contains(isSummaryEventCounter(flag2, 2, value, 1))) ) )); } - + @SuppressWarnings("unchecked") @Test public void customEventIsQueuedWithUser() throws Exception { @@ -342,13 +369,12 @@ public void customEventIsQueuedWithUser() throws Exception { ep.sendEvent(ce); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( + assertThat(output, contains( isIndexEvent(ce, userJson), isCustomEvent(ce, null) )); } - @SuppressWarnings("unchecked") @Test public void customEventCanContainInlineUser() throws Exception { configBuilder.inlineUsersInEvents(true); @@ -359,10 +385,9 @@ public void customEventCanContainInlineUser() throws Exception { ep.sendEvent(ce); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isCustomEvent(ce, userJson))); + assertThat(output, contains(isCustomEvent(ce, userJson))); } - @SuppressWarnings("unchecked") @Test public void userIsFilteredInCustomEvent() throws Exception { configBuilder.inlineUsersInEvents(true).allAttributesPrivate(true); @@ -373,10 +398,9 @@ public void userIsFilteredInCustomEvent() throws Exception { ep.sendEvent(ce); JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isCustomEvent(ce, filteredUserJson))); + assertThat(output, contains(isCustomEvent(ce, filteredUserJson))); } - @SuppressWarnings("unchecked") @Test public void closingEventProcessorForcesSynchronousFlush() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); @@ -386,7 +410,7 @@ public void closingEventProcessorForcesSynchronousFlush() throws Exception { server.enqueue(new MockResponse()); ep.close(); JsonArray output = getEventsFromLastRequest(); - assertThat(output, hasItems(isIdentifyEvent(e, userJson))); + assertThat(output, contains(isIdentifyEvent(e, userJson))); } @Test @@ -396,7 +420,55 @@ public void nothingIsSentIfThereAreNoEvents() throws Exception { assertEquals(0, server.getRequestCount()); } - + + @Test + public void initialDiagnosticEventSentToDiagnosticEndpoint() throws Exception { + server.enqueue(new MockResponse()); + ep = new DefaultEventProcessor(SDK_KEY, diagConfigBuilder.build()); + ep.close(); + RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); + + assertNotNull(req); + assertThat(req.getPath(), equalTo("//diagnostic")); + } + + @Test + public void initialDiagnosticEventHasInitBody() throws Exception { + server.enqueue(new MockResponse()); + ep = new DefaultEventProcessor(SDK_KEY, diagConfigBuilder.build()); + ep.close(); + RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); + assertNotNull(req); + + DiagnosticEvent.Init initEvent = gson.fromJson(req.getBody().readUtf8(), DiagnosticEvent.Init.class); + + assertNotNull(initEvent); + assertThat(initEvent.kind, equalTo("diagnostic-init")); + assertNotNull(initEvent.configuration); + assertNotNull(initEvent.sdk); + assertNotNull(initEvent.platform); + assertNotNull(initEvent.id); + } + + @Test + public void periodicDiagnosticEventHasStatisticsBody() throws Exception { + server.enqueue(new MockResponse()); + server.enqueue(new MockResponse()); + ep = new DefaultEventProcessor(SDK_KEY, diagConfigBuilder.build()); + ep.postDiagnostic(); + ep.close(); + // Ignore the initial diagnostic event + server.takeRequest(100, TimeUnit.MILLISECONDS); + RecordedRequest periodReq = server.takeRequest(100, TimeUnit.MILLISECONDS); + assertNotNull(periodReq); + + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodReq.getBody().readUtf8(), DiagnosticEvent.Statistics.class); + + assertNotNull(statsEvent); + assertThat(statsEvent.kind, equalTo("diagnostic")); + assertNotNull(statsEvent.id); + } + @Test public void sdkKeyIsSent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); @@ -410,6 +482,16 @@ public void sdkKeyIsSent() throws Exception { assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); } + @Test + public void sdkKeyIsSentOnDiagnosticEvents() throws Exception { + server.enqueue(new MockResponse()); + ep = new DefaultEventProcessor(SDK_KEY, diagConfigBuilder.build()); + ep.close(); + RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); + assertNotNull(req); + assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); + } + @Test public void eventSchemaIsSent() throws Exception { ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); @@ -423,6 +505,51 @@ public void eventSchemaIsSent() throws Exception { assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("3")); } + @Test + public void eventSchemaNotSetOnDiagnosticEvents() throws Exception { + server.enqueue(new MockResponse()); + ep = new DefaultEventProcessor(SDK_KEY, diagConfigBuilder.build()); + ep.close(); + RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); + assertNotNull(req); + assertNull(req.getHeader("X-LaunchDarkly-Event-Schema")); + } + + @Test + public void wrapperHeaderSentWhenSet() throws Exception { + LDConfig config = configBuilder + .wrapperName("Scala") + .wrapperVersion("0.1.0") + .build(); + + ep = new DefaultEventProcessor(SDK_KEY, config); + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + ep.sendEvent(e); + + server.enqueue(new MockResponse()); + ep.close(); + RecordedRequest req = server.takeRequest(); + + assertThat(req.getHeader("X-LaunchDarkly-Wrapper"), equalTo("Scala/0.1.0")); + } + + @Test + public void wrapperHeaderSentWithoutVersion() throws Exception { + LDConfig config = configBuilder + .wrapperName("Scala") + .build(); + + ep = new DefaultEventProcessor(SDK_KEY, config); + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + ep.sendEvent(e); + + server.enqueue(new MockResponse()); + ep.close(); + RecordedRequest req = server.takeRequest(); + + assertThat(req.getHeader("X-LaunchDarkly-Wrapper"), equalTo("Scala")); + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); @@ -573,7 +700,7 @@ private Matcher isSummaryEvent(long startDate, long endDate) { ); } - private Matcher hasSummaryFlag(String key, JsonElement defaultVal, Matcher> counters) { + private Matcher hasSummaryFlag(String key, JsonElement defaultVal, Matcher> counters) { return hasJsonProperty("features", hasJsonProperty(key, allOf( hasJsonProperty("default", defaultVal), diff --git a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java b/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java new file mode 100644 index 000000000..2e261f920 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java @@ -0,0 +1,52 @@ +package com.launchdarkly.client; + +import org.junit.Test; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +public class DiagnosticAccumulatorTest { + + @Test + public void startSetsDiagnosticId() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + long currentTime = System.currentTimeMillis(); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); + diagnosticAccumulator.start(diagnosticId, currentTime); + assertSame(diagnosticId, diagnosticAccumulator.diagnosticId); + } + + @Test + public void startSetsDataSinceDate() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + long currentTime = System.currentTimeMillis(); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); + diagnosticAccumulator.start(diagnosticId, currentTime); + assertEquals(currentTime, diagnosticAccumulator.dataSinceDate); + } + + @Test + public void createsDiagnosticStatisticsEvent() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + long currentTime = System.currentTimeMillis(); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); + diagnosticAccumulator.start(diagnosticId, currentTime); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = diagnosticAccumulator.createEventAndReset(10, 15, 20); + assertSame(diagnosticId, diagnosticStatisticsEvent.id); + assertEquals(10, diagnosticStatisticsEvent.droppedEvents); + assertEquals(15, diagnosticStatisticsEvent.deduplicatedUsers); + assertEquals(20, diagnosticStatisticsEvent.eventsInQueue); + assertEquals(currentTime, diagnosticStatisticsEvent.dataSinceDate); + } + + @Test + public void resetsDataSinceDate() throws InterruptedException { + long currentTime = System.currentTimeMillis(); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); + diagnosticAccumulator.start(null, currentTime); + Thread.sleep(2); + diagnosticAccumulator.createEventAndReset(0, 0, 0); + assertNotEquals(currentTime, diagnosticAccumulator.dataSinceDate); + } +} diff --git a/src/test/java/com/launchdarkly/client/DiagnosticIdTest.java b/src/test/java/com/launchdarkly/client/DiagnosticIdTest.java new file mode 100644 index 000000000..7c2402e42 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/DiagnosticIdTest.java @@ -0,0 +1,52 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import org.junit.Test; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class DiagnosticIdTest { + + private static final Gson gson = new Gson(); + + @Test + public void hasUUID() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + assertNotNull(diagnosticId.diagnosticId); + assertNotNull(UUID.fromString(diagnosticId.diagnosticId)); + } + + @Test + public void nullKeyIsSafe() { + // We can't send diagnostics without a key anyway, so we're just validating that the + // constructor won't crash with a null key + new DiagnosticId(null); + } + + @Test + public void shortKeyIsSafe() { + DiagnosticId diagnosticId = new DiagnosticId("foo"); + assertEquals("foo", diagnosticId.sdkKeySuffix); + } + + @Test + public void keyIsSuffix() { + DiagnosticId diagnosticId = new DiagnosticId("this_is_a_fake_key"); + assertEquals("ke_key", diagnosticId.sdkKeySuffix); + } + + @Test + public void gsonSerialization() { + DiagnosticId diagnosticId = new DiagnosticId("this_is_a_fake_key"); + JsonObject jsonObject = gson.toJsonTree(diagnosticId).getAsJsonObject(); + assertEquals(2, jsonObject.size()); + String id = jsonObject.getAsJsonPrimitive("diagnosticId").getAsString(); + assertNotNull(UUID.fromString(id)); + assertEquals("ke_key", jsonObject.getAsJsonPrimitive("sdkKeySuffix").getAsString()); + } +} diff --git a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java new file mode 100644 index 000000000..ccdd229e0 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java @@ -0,0 +1,60 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.launchdarkly.client.DiagnosticEvent.Init.DiagnosticSdk; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class DiagnosticSdkTest { + + private static final Gson gson = new Gson(); + + @Test + public void defaultFieldValues() { + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(new LDConfig.Builder().build()); + assertEquals("java-server-sdk", diagnosticSdk.name); + assertEquals(LDClient.CLIENT_VERSION, diagnosticSdk.version); + assertNull(diagnosticSdk.wrapperName); + assertNull(diagnosticSdk.wrapperVersion); + } + + @Test + public void getsWrapperValuesFromConfig() { + LDConfig config = new LDConfig.Builder() + .wrapperName("Scala") + .wrapperVersion("0.1.0") + .build(); + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); + assertEquals("java-server-sdk", diagnosticSdk.name); + assertEquals(LDClient.CLIENT_VERSION, diagnosticSdk.version); + assertEquals(diagnosticSdk.wrapperName, "Scala"); + assertEquals(diagnosticSdk.wrapperVersion, "0.1.0"); + } + + @Test + public void gsonSerializationNoWrapper() { + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(new LDConfig.Builder().build()); + JsonObject jsonObject = gson.toJsonTree(diagnosticSdk).getAsJsonObject(); + assertEquals(2, jsonObject.size()); + assertEquals("java-server-sdk", jsonObject.getAsJsonPrimitive("name").getAsString()); + assertEquals(LDClient.CLIENT_VERSION, jsonObject.getAsJsonPrimitive("version").getAsString()); + } + + @Test + public void gsonSerializationWithWrapper() { + LDConfig config = new LDConfig.Builder() + .wrapperName("Scala") + .wrapperVersion("0.1.0") + .build(); + DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); + JsonObject jsonObject = gson.toJsonTree(diagnosticSdk).getAsJsonObject(); + assertEquals(4, jsonObject.size()); + assertEquals("java-server-sdk", jsonObject.getAsJsonPrimitive("name").getAsString()); + assertEquals(LDClient.CLIENT_VERSION, jsonObject.getAsJsonPrimitive("version").getAsString()); + assertEquals("Scala", jsonObject.getAsJsonPrimitive("wrapperName").getAsString()); + assertEquals("0.1.0", jsonObject.getAsJsonPrimitive("wrapperVersion").getAsString()); + } +} diff --git a/src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java new file mode 100644 index 000000000..5ec9102bd --- /dev/null +++ b/src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java @@ -0,0 +1,46 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +public class DiagnosticStatisticsEventTest { + + @Test + public void testConstructor() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3); + assertEquals("diagnostic", diagnosticStatisticsEvent.kind); + assertEquals(2000, diagnosticStatisticsEvent.creationDate); + assertSame(diagnosticId, diagnosticStatisticsEvent.id); + assertEquals(1000, diagnosticStatisticsEvent.dataSinceDate); + assertEquals(1, diagnosticStatisticsEvent.droppedEvents); + assertEquals(2, diagnosticStatisticsEvent.deduplicatedUsers); + assertEquals(3, diagnosticStatisticsEvent.eventsInQueue); + } + + @Test + public void testSerialization() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3); + Gson gson = new Gson(); + JsonObject jsonObject = gson.toJsonTree(diagnosticStatisticsEvent).getAsJsonObject(); + assertEquals(7, jsonObject.size()); + assertEquals("diagnostic", diagnosticStatisticsEvent.kind); + assertEquals(2000, jsonObject.getAsJsonPrimitive("creationDate").getAsLong()); + JsonObject idObject = jsonObject.getAsJsonObject("id"); + assertEquals("DK_KEY", idObject.getAsJsonPrimitive("sdkKeySuffix").getAsString()); + assertNotNull(UUID.fromString(idObject.getAsJsonPrimitive("diagnosticId").getAsString())); + assertEquals(1000, jsonObject.getAsJsonPrimitive("dataSinceDate").getAsLong()); + assertEquals(1, jsonObject.getAsJsonPrimitive("droppedEvents").getAsLong()); + assertEquals(2, jsonObject.getAsJsonPrimitive("deduplicatedUsers").getAsLong()); + assertEquals(3, jsonObject.getAsJsonPrimitive("eventsInQueue").getAsLong()); + } + +} diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index 1cca3a8c8..8b56ac707 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -6,8 +6,10 @@ import java.net.Proxy; 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; public class LDConfigTest { @Test @@ -118,4 +120,50 @@ public void testSendEventsCanBeSetToFalse() { LDConfig config = new LDConfig.Builder().sendEvents(false).build(); assertEquals(false, config.sendEvents); } + + @Test + public void testDefaultDiagnosticRecordingInterval() { + LDConfig config = new LDConfig.Builder().build(); + assertEquals(900_000, config.diagnosticRecordingIntervalMillis); + } + + @Test + public void testDiagnosticRecordingInterval() { + LDConfig config = new LDConfig.Builder().diagnosticRecordingIntervalMillis(120_000).build(); + assertEquals(120_000, config.diagnosticRecordingIntervalMillis); + } + + @Test + public void testMinimumDiagnosticRecordingIntervalEnforced() { + LDConfig config = new LDConfig.Builder().diagnosticRecordingIntervalMillis(10).build(); + assertEquals(60_000, config.diagnosticRecordingIntervalMillis); + } + + @Test + public void testDefaultDiagnosticOptOut() { + LDConfig config = new LDConfig.Builder().build(); + assertFalse(config.diagnosticOptOut); + } + + @Test + public void testDiagnosticOptOut() { + LDConfig config = new LDConfig.Builder().diagnosticOptOut(true).build(); + assertTrue(config.diagnosticOptOut); + } + + @Test + public void testWrapperNotConfigured() { + LDConfig config = new LDConfig.Builder().build(); + assertNull(config.wrapperName); + assertNull(config.wrapperVersion); + } + + @Test public void testWrapperConfigured() { + LDConfig config = new LDConfig.Builder() + .wrapperName("Scala") + .wrapperVersion("0.1.0") + .build(); + assertEquals("Scala", config.wrapperName); + assertEquals("0.1.0", config.wrapperVersion); + } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index ee29937c9..1aa1a1413 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -82,6 +82,16 @@ public void headersHaveAccept() { assertEquals("text/event-stream", headers.get("Accept")); } + @Test + public void headersHaveWrapperWhenSet() { + LDConfig config = configBuilder + .wrapperName("Scala") + .wrapperVersion("0.1.0") + .build(); + createStreamProcessor(SDK_KEY, config).start(); + assertEquals("Scala/0.1.0", headers.get("X-LaunchDarkly-Wrapper")); + } + @Test public void putCausesFeatureToBeStored() throws Exception { createStreamProcessor(SDK_KEY, configBuilder.build()).start(); diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 422db127d..d791fc939 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -241,7 +241,7 @@ protected boolean matchesSafely(JsonElement item, Description mismatchDescriptio }; } - public static Matcher isJsonArray(final Matcher> matcher) { + public static Matcher isJsonArray(final Matcher> matcher) { return new TypeSafeDiagnosingMatcher() { @Override public void describeTo(Description description) { From d7f17246402e2ee45852db942a50d238bf055f53 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 31 Jul 2019 23:34:07 -0700 Subject: [PATCH 161/327] deprecate samplingInterval --- src/main/java/com/launchdarkly/client/LDConfig.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 938761b48..4c07a5032 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -223,7 +223,7 @@ public Builder streamURI(URI streamURI) { * you may use {@link RedisFeatureStore} or a custom implementation. * @param store the feature store implementation * @return the builder - * @deprecated Please use {@link #featureStoreFactory}. + * @deprecated Please use {@link #featureStoreFactory(FeatureStoreFactory)}. */ public Builder featureStore(FeatureStore store) { this.featureStore = store; @@ -446,7 +446,7 @@ public Builder allAttributesPrivate(boolean allPrivate) { /** * Set whether to send events back to LaunchDarkly. By default, the client will send - * events. This differs from {@link offline} in that it only affects sending + * events. This differs from {@link #offline(boolean)} in that it only affects sending * analytics events, not streaming or polling for events from the server. * * @param sendEvents when set to false, no events will be sent to LaunchDarkly @@ -488,9 +488,11 @@ public Builder startWaitMillis(long startWaitMillis) { * samplingInterval chance events will be will be sent. *

    Example: if you want 5% sampling rate, set samplingInterval to 20. * - * @param samplingInterval the sampling interval. + * @param samplingInterval the sampling interval * @return the builder + * @deprecated This feature will be removed in a future version of the SDK. */ + @Deprecated public Builder samplingInterval(int samplingInterval) { this.samplingInterval = samplingInterval; return this; From da034253fb788fdd74230fb3713f6f07e47764a5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 1 Aug 2019 11:14:54 -0700 Subject: [PATCH 162/327] add password to Redis builder --- .../client/RedisFeatureStoreBuilder.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index e277267ac..cf8eab4a0 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -49,6 +49,7 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { String prefix = DEFAULT_PREFIX; int connectTimeout = Protocol.DEFAULT_TIMEOUT; int socketTimeout = Protocol.DEFAULT_TIMEOUT; + String password = null; FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCacheConfig, but are used by deprecated setters boolean asyncRefresh = false; @@ -213,6 +214,17 @@ public RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUn return this; } + /** + * Specifies a password that will be sent to Redis in an AUTH command. + * + * @param password the password + * @return the builder + */ + public RedisFeatureStoreBuilder password(String password) { + this.password = password; + return this; + } + /** * Build a {@link RedisFeatureStore} based on the currently configured builder object. * @return the {@link RedisFeatureStore} configured by this builder. From 1a8f40f73fa4bea12a3f857545c500c16cf9b165 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Aug 2019 13:31:07 -0700 Subject: [PATCH 163/327] add polling mode tests and end-to-end streaming test --- .../com/launchdarkly/client/FeatureFlag.java | 11 -- .../launchdarkly/client/FeatureRequestor.java | 16 +- .../java/com/launchdarkly/client/Segment.java | 11 -- .../client/FeatureRequestorTest.java | 166 ++++++++++++++++++ .../client/LDClientEndToEndTest.java | 116 ++++++++++++ .../com/launchdarkly/client/TestHttpUtil.java | 63 +++++++ 6 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 src/test/java/com/launchdarkly/client/FeatureRequestorTest.java create mode 100644 src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java create mode 100644 src/test/java/com/launchdarkly/client/TestHttpUtil.java diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 7cc7dde91..c9ed7f31c 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -17,9 +17,6 @@ class FeatureFlag implements VersionedData { private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); - private static final Type mapType = new TypeToken>() { - }.getType(); - private String key; private int version; private boolean on; @@ -35,14 +32,6 @@ class FeatureFlag implements VersionedData { private Long debugEventsUntilDate; private boolean deleted; - static FeatureFlag fromJson(LDConfig config, String json) { - return config.gson.fromJson(json, FeatureFlag.class); - } - - static Map fromJsonMap(LDConfig config, String json) { - return config.gson.fromJson(json, mapType); - } - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation FeatureFlag() {} diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index 180270295..f4a031a5e 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -37,24 +37,14 @@ static class AllData { this.config = config; } - Map getAllFlags() throws IOException, HttpErrorException { - String body = get(GET_LATEST_FLAGS_PATH); - return FeatureFlag.fromJsonMap(config, body); - } - FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return FeatureFlag.fromJson(config, body); - } - - Map getAllSegments() throws IOException, HttpErrorException { - String body = get(GET_LATEST_SEGMENTS_PATH); - return Segment.fromJsonMap(config, body); + return config.gson.fromJson(body, FeatureFlag.class); } Segment getSegment(String segmentKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return Segment.fromJson(config, body); + return config.gson.fromJson(body, Segment.class); } AllData getAllData() throws IOException, HttpErrorException { @@ -71,7 +61,7 @@ AllData getAllData() throws IOException, HttpErrorException { private String get(String path) throws IOException, HttpErrorException { Request request = getRequestBuilder(sdkKey) - .url(config.baseURI.toString() + path) + .url(config.baseURI.resolve(path).toURL()) .get() .build(); diff --git a/src/main/java/com/launchdarkly/client/Segment.java b/src/main/java/com/launchdarkly/client/Segment.java index eb7fc24d8..2febf6b65 100644 --- a/src/main/java/com/launchdarkly/client/Segment.java +++ b/src/main/java/com/launchdarkly/client/Segment.java @@ -9,9 +9,6 @@ import com.google.gson.reflect.TypeToken; class Segment implements VersionedData { - - private static final Type mapType = new TypeToken>() { }.getType(); - private String key; private List included; private List excluded; @@ -20,14 +17,6 @@ class Segment implements VersionedData { private int version; private boolean deleted; - static Segment fromJson(LDConfig config, String json) { - return config.gson.fromJson(json, Segment.class); - } - - static Map fromJsonMap(LDConfig config, String json) { - return config.gson.fromJson(json, mapType); - } - // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation Segment() {} diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java new file mode 100644 index 000000000..f179a93dc --- /dev/null +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -0,0 +1,166 @@ +package com.launchdarkly.client; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.client.TestHttpUtil.baseConfig; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +public class FeatureRequestorTest { + private static final String sdkKey = "sdk-key"; + private static final String flag1Key = "flag1"; + private static final String flag1Json = "{\"key\":\"" + flag1Key + "\"}"; + private static final String flagsJson = "{\"" + flag1Key + "\":" + flag1Json + "}"; + private static final String segment1Key = "segment1"; + private static final String segment1Json = "{\"key\":\"" + segment1Key + "\"}"; + private static final String segmentsJson = "{\"" + segment1Key + "\":" + segment1Json + "}"; + private static final String allDataJson = "{\"flags\":" + flagsJson + ",\"segments\":" + segmentsJson + "}"; + + @Test + public void requestAllData() throws Exception { + MockResponse resp = new MockResponse(); + resp.setHeader("Content-Type", "application/json"); + resp.setBody(allDataJson); + + try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { + FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); + + FeatureRequestor.AllData data = r.getAllData(); + + RecordedRequest req = server.takeRequest(); + assertEquals("/sdk/latest-all", req.getPath()); + verifyHeaders(req); + + assertNotNull(data); + assertNotNull(data.flags); + assertNotNull(data.segments); + assertEquals(1, data.flags.size()); + assertEquals(1, data.flags.size()); + verifyFlag(data.flags.get(flag1Key), flag1Key); + verifySegment(data.segments.get(segment1Key), segment1Key); + } + } + + @Test + public void requestFlag() throws Exception { + MockResponse resp = new MockResponse(); + resp.setHeader("Content-Type", "application/json"); + resp.setBody(flag1Json); + + try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { + FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); + + FeatureFlag flag = r.getFlag(flag1Key); + + RecordedRequest req = server.takeRequest(); + assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); + verifyHeaders(req); + + verifyFlag(flag, flag1Key); + } + } + + @Test + public void requestSegment() throws Exception { + MockResponse resp = new MockResponse(); + resp.setHeader("Content-Type", "application/json"); + resp.setBody(segment1Json); + + try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { + FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); + + Segment segment = r.getSegment(segment1Key); + + RecordedRequest req = server.takeRequest(); + assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); + verifyHeaders(req); + + verifySegment(segment, segment1Key); + } + } + + @Test + public void requestFlagNotFound() throws Exception { + MockResponse notFoundResp = new MockResponse().setResponseCode(404); + + try (MockWebServer server = TestHttpUtil.makeStartedServer(notFoundResp)) { + FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); + + try { + r.getFlag(flag1Key); + Assert.fail("expected exception"); + } catch (HttpErrorException e) { + assertEquals(404, e.getStatus()); + } + } + } + + @Test + public void requestSegmentNotFound() throws Exception { + MockResponse notFoundResp = new MockResponse().setResponseCode(404); + + try (MockWebServer server = TestHttpUtil.makeStartedServer(notFoundResp)) { + FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); + + try { + r.getSegment(segment1Key); + Assert.fail("expected exception"); + } catch (HttpErrorException e) { + assertEquals(404, e.getStatus()); + } + } + } + + @Test + public void requestsAreCached() throws Exception { + MockResponse cacheableResp = new MockResponse(); + cacheableResp.setHeader("Content-Type", "application/json"); + cacheableResp.setHeader("ETag", "aaa"); + cacheableResp.setHeader("Cache-Control", "max-age=1000"); + cacheableResp.setBody(flag1Json); + + try (MockWebServer server = TestHttpUtil.makeStartedServer(cacheableResp)) { + FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); + + FeatureFlag flag1a = r.getFlag(flag1Key); + + RecordedRequest req1 = server.takeRequest(); + assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); + verifyHeaders(req1); + + verifyFlag(flag1a, flag1Key); + + FeatureFlag flag1b = r.getFlag(flag1Key); + verifyFlag(flag1b, flag1Key); + assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit + } + } + + private LDConfig.Builder basePollingConfig(MockWebServer server) { + return baseConfig(server) + .stream(false); + } + + private void verifyHeaders(RecordedRequest req) { + assertEquals(sdkKey, req.getHeader("Authorization")); + assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); + } + + private void verifyFlag(FeatureFlag flag, String key) { + assertNotNull(flag); + assertEquals(key, flag.getKey()); + } + + private void verifySegment(Segment segment, String key) { + assertNotNull(segment); + assertEquals(key, segment.getKey()); + } +} diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java new file mode 100644 index 000000000..135c2b40f --- /dev/null +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -0,0 +1,116 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.client.TestHttpUtil.baseConfig; +import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +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 okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.mockwebserver.SocketPolicy; + +public class LDClientEndToEndTest { + private static final Gson gson = new Gson(); + private static final String sdkKey = "sdk-key"; + private static final String flagKey = "flag1"; + private static final FeatureFlag flag = new FeatureFlagBuilder(flagKey) + .offVariation(0).variations(new JsonPrimitive(true)) + .build(); + private static final LDUser user = new LDUser("user-key"); + + @Test + public void clientStartsInPollingMode() throws Exception { + MockResponse resp = new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody(makeAllDataJson()); + + try (MockWebServer server = makeStartedServer(resp)) { + LDConfig config = baseConfig(server) + .stream(false) + .sendEvents(false) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.initialized()); + assertTrue(client.boolVariation(flagKey, user, false)); + } + } + } + + @Test + public void clientFailsInPollingModeWith401Error() throws Exception { + MockResponse resp = new MockResponse().setResponseCode(401); + + try (MockWebServer server = makeStartedServer(resp)) { + LDConfig config = baseConfig(server) + .stream(false) + .sendEvents(false) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertFalse(client.initialized()); + assertFalse(client.boolVariation(flagKey, user, false)); + } + } + } + + @Test + public void clientStartsInStreamingMode() throws Exception { + String eventData = "event: put\n" + + "data: {\"data\":" + makeAllDataJson() + "}\n\n"; + + MockResponse resp = new MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setChunkedBody(eventData, 1000) + .setSocketPolicy(SocketPolicy.KEEP_OPEN); + + try (MockWebServer server = makeStartedServer(resp)) { + LDConfig config = baseConfig(server) + .sendEvents(false) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.initialized()); + assertTrue(client.boolVariation(flagKey, user, false)); + } + } + } + + @Test + public void clientFailsInStreamingModeWith401Error() throws Exception { + MockResponse resp = new MockResponse().setResponseCode(401); + + try (MockWebServer server = makeStartedServer(resp)) { + LDConfig config = baseConfig(server) + .sendEvents(false) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertFalse(client.initialized()); + assertFalse(client.boolVariation(flagKey, user, false)); + } + } + } + + public String makeAllDataJson() { + JsonObject flagsData = new JsonObject(); + flagsData.add(flagKey, gson.toJsonTree(flag)); + JsonObject allData = new JsonObject(); + allData.add("flags", flagsData); + allData.add("segments", new JsonObject()); + return gson.toJson(allData); + } +} diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java new file mode 100644 index 000000000..5c1f6371d --- /dev/null +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -0,0 +1,63 @@ +package com.launchdarkly.client; + +import java.io.Closeable; +import java.io.IOException; +import java.net.InetAddress; +import java.net.URI; +import java.security.GeneralSecurityException; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.internal.tls.HeldCertificate; +import okhttp3.mockwebserver.internal.tls.SslClient; + +class TestHttpUtil { + static MockWebServer makeStartedServer(MockResponse... responses) throws IOException { + MockWebServer server = new MockWebServer(); + for (MockResponse r: responses) { + server.enqueue(r); + } + server.start(); + return server; + } + + static LDConfig.Builder baseConfig(MockWebServer server) { + URI uri = server.url("").uri(); + return new LDConfig.Builder() + .baseURI(uri) + .streamURI(uri) + .eventsURI(uri); + } + + static class HttpsServerWithSelfSignedCert implements Closeable { + final MockWebServer server; + final HeldCertificate cert; + final SslClient sslClient; + + public HttpsServerWithSelfSignedCert() throws IOException, GeneralSecurityException { + cert = new HeldCertificate.Builder() + .serialNumber("1") + .commonName(InetAddress.getByName("localhost").getCanonicalHostName()) + .subjectAlternativeName("localhost") + .build(); + + sslClient = new SslClient.Builder() + .certificateChain(cert.keyPair, cert.certificate) + .addTrustedCertificate(cert.certificate) + .build(); + + server = new MockWebServer(); + server.useHttps(sslClient.socketFactory, false); + + server.start(); + } + + public URI uri() { + return server.url("/").uri(); + } + + public void close() throws IOException { + server.close(); + } + } +} From e70355af4042b373a1d399b7243313dd15d1ce96 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Aug 2019 13:52:11 -0700 Subject: [PATCH 164/327] revise custom TLS config implementation, add tests --- build.gradle | 2 +- .../com/launchdarkly/client/LDConfig.java | 66 +- .../launchdarkly/client/StreamProcessor.java | 55 +- src/test/java/EliTest.java | 64 ++ .../client/DefaultEventProcessorTest.java | 580 ++++++++++-------- .../client/FeatureRequestorTest.java | 60 +- .../client/LDClientEndToEndTest.java | 65 +- .../client/StreamProcessorTest.java | 72 ++- .../client/TestHttpCertificates.java | 164 +++++ .../com/launchdarkly/client/TestHttpUtil.java | 27 +- .../com/launchdarkly/client/TestUtil.java | 2 +- 11 files changed, 818 insertions(+), 339 deletions(-) create mode 100644 src/test/java/EliTest.java create mode 100644 src/test/java/com/launchdarkly/client/TestHttpCertificates.java diff --git a/build.gradle b/build.gradle index ad3f59aaf..6d80f9a7f 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.1", + "com.launchdarkly:okhttp-eventsource:1.10.0", "org.yaml:snakeyaml:1.19", "redis.clients:jedis:2.9.0" ] diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 7fddd0961..3044c604e 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -75,7 +75,9 @@ public final class LDConfig { final int userKeysCapacity; final int userKeysFlushInterval; final boolean inlineUsersInEvents; - + final SSLSocketFactory sslSocketFactory; + final X509TrustManager trustManager; + protected LDConfig(Builder builder) { this.baseURI = builder.baseURI; this.eventsURI = builder.eventsURI; @@ -105,9 +107,20 @@ protected LDConfig(Builder builder) { this.userKeysCapacity = builder.userKeysCapacity; this.userKeysFlushInterval = builder.userKeysFlushInterval; this.inlineUsersInEvents = builder.inlineUsersInEvents; + this.sslSocketFactory = builder.sslSocketFactory; + this.trustManager = builder.trustManager; - OkHttpClient.Builder httpClientBuilder = builder.clientBuilder; + OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder() + .connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) + .connectTimeout(builder.connectTimeout, builder.connectTimeoutUnit) + .readTimeout(builder.socketTimeout, builder.socketTimeoutUnit) + .writeTimeout(builder.socketTimeout, builder.socketTimeoutUnit) + .retryOnConnectionFailure(false); // we will implement our own retry logic + if (sslSocketFactory != null) { + httpClientBuilder.sslSocketFactory(sslSocketFactory, trustManager); + } + // When streaming is enabled, http GETs made by FeatureRequester will // always guarantee a new flag state. So, disable http response caching // when streaming. @@ -145,8 +158,10 @@ public static class Builder { private URI baseURI = DEFAULT_BASE_URI; private URI eventsURI = DEFAULT_EVENTS_URI; private URI streamURI = DEFAULT_STREAM_URI; - private int connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT_MILLIS; - private int socketTimeoutMillis = DEFAULT_SOCKET_TIMEOUT_MILLIS; + private int connectTimeout = DEFAULT_CONNECT_TIMEOUT_MILLIS; + private TimeUnit connectTimeoutUnit = TimeUnit.MILLISECONDS; + private int socketTimeout = DEFAULT_SOCKET_TIMEOUT_MILLIS; + private TimeUnit socketTimeoutUnit = TimeUnit.MILLISECONDS; private int capacity = DEFAULT_CAPACITY; private int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; private String proxyHost = "localhost"; @@ -170,12 +185,8 @@ public static class Builder { private int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; private int userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; private boolean inlineUsersInEvents = false; - private OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder() - .connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) - .connectTimeout(DEFAULT_CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - .readTimeout(DEFAULT_SOCKET_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - .writeTimeout(DEFAULT_SOCKET_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) - .retryOnConnectionFailure(false); // we will implement our own retry logic; + private SSLSocketFactory sslSocketFactory = null; + private X509TrustManager trustManager = null; /** * Creates a builder with all configuration parameters set to the default @@ -290,7 +301,8 @@ public Builder stream(boolean stream) { * @return the builder */ public Builder connectTimeout(int connectTimeout) { - this.clientBuilder.connectTimeout(connectTimeout, TimeUnit.SECONDS); + this.connectTimeout = connectTimeout; + this.connectTimeoutUnit = TimeUnit.SECONDS; return this; } @@ -303,8 +315,8 @@ public Builder connectTimeout(int connectTimeout) { * @return the builder */ public Builder socketTimeout(int socketTimeout) { - this.clientBuilder.readTimeout(socketTimeout, TimeUnit.SECONDS); - this.clientBuilder.writeTimeout(socketTimeout, TimeUnit.SECONDS); + this.socketTimeout = socketTimeout; + this.socketTimeoutUnit = TimeUnit.SECONDS; return this; } @@ -317,7 +329,8 @@ public Builder socketTimeout(int socketTimeout) { * @return the builder */ public Builder connectTimeoutMillis(int connectTimeoutMillis) { - this.clientBuilder.connectTimeout(connectTimeoutMillis, TimeUnit.MILLISECONDS); + this.connectTimeout = connectTimeoutMillis; + this.connectTimeoutUnit = TimeUnit.MILLISECONDS; return this; } @@ -330,8 +343,8 @@ public Builder connectTimeoutMillis(int connectTimeoutMillis) { * @return the builder */ public Builder socketTimeoutMillis(int socketTimeoutMillis) { - this.clientBuilder.readTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS); - this.clientBuilder.writeTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS); + this.socketTimeout = socketTimeoutMillis; + this.socketTimeoutUnit = TimeUnit.MILLISECONDS; return this; } @@ -413,26 +426,13 @@ public Builder proxyPassword(String password) { /** * Sets the {@link SSLSocketFactory} used to secure HTTPS connections to LaunchDarkly. * - * @param sslSocketFactory the ssl socket factory + * @param sslSocketFactory the SSL socket factory * @param trustManager the trust manager * @return the builder */ - public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, - X509TrustManager trustManager) { - this.clientBuilder.sslSocketFactory(sslSocketFactory, trustManager); - return this; - } - - /** - * Sets a underlying {@link OkHttpClient} used for making connections to LaunchDarkly. - * If you're setting this along with other connection-related items (ie timeouts, proxy), - * you should do this first to avoid overwriting values. - * - * @param httpClient the http client - * @return the builder - */ - public Builder httpClient(OkHttpClient httpClient) { - this.clientBuilder = httpClient.newBuilder(); + public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { + this.sslSocketFactory = sslSocketFactory; + this.trustManager = trustManager; return this; } diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 50d385cd7..2ac867af8 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -9,8 +9,6 @@ import com.launchdarkly.eventsource.MessageEvent; import com.launchdarkly.eventsource.UnsuccessfulResponseException; -import java.util.concurrent.TimeUnit; -import okhttp3.ConnectionPool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,6 +23,7 @@ import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import okhttp3.Headers; +import okhttp3.OkHttpClient; final class StreamProcessor implements UpdateProcessor { private static final String PUT = "put"; @@ -43,8 +42,10 @@ final class StreamProcessor implements UpdateProcessor { private volatile EventSource es; private final AtomicBoolean initialized = new AtomicBoolean(false); + ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing + public static interface EventSourceCreator { - EventSource createEventSource(EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers); + EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers); } StreamProcessor(String sdkKey, LDConfig config, FeatureRequestor requestor, FeatureStore featureStore, @@ -56,6 +57,22 @@ public static interface EventSourceCreator { this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : new DefaultEventSourceCreator(); } + private ConnectionErrorHandler createDefaultConnectionErrorHandler() { + return new ConnectionErrorHandler() { + @Override + public Action onConnectionError(Throwable t) { + if (t instanceof UnsuccessfulResponseException) { + int status = ((UnsuccessfulResponseException)t).getCode(); + logger.error(httpErrorMessage(status, "streaming connection", "will retry")); + if (!isHttpErrorRecoverable(status)) { + return Action.SHUTDOWN; + } + } + return Action.PROCEED; + } + }; + } + @Override public Future start() { final SettableFuture initFuture = SettableFuture.create(); @@ -66,18 +83,14 @@ public Future start() { .add("Accept", "text/event-stream") .build(); - ConnectionErrorHandler connectionErrorHandler = new ConnectionErrorHandler() { + ConnectionErrorHandler wrappedConnectionErrorHandler = new ConnectionErrorHandler() { @Override public Action onConnectionError(Throwable t) { - if (t instanceof UnsuccessfulResponseException) { - int status = ((UnsuccessfulResponseException)t).getCode(); - logger.error(httpErrorMessage(status, "streaming connection", "will retry")); - if (!isHttpErrorRecoverable(status)) { - initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited - return Action.SHUTDOWN; - } + Action result = connectionErrorHandler.onConnectionError(t); + if (result == Action.SHUTDOWN) { + initFuture.set(null); // if client is initializing, make it stop waiting; has no effect if already inited } - return Action.PROCEED; + return result; } }; @@ -176,9 +189,9 @@ public void onError(Throwable throwable) { } }; - es = eventSourceCreator.createEventSource(handler, + es = eventSourceCreator.createEventSource(config, handler, URI.create(config.streamURI.toASCIIString() + "/all"), - connectionErrorHandler, + wrappedConnectionErrorHandler, headers); es.start(); return initFuture; @@ -227,13 +240,15 @@ public DeleteData() { } private class DefaultEventSourceCreator implements EventSourceCreator { - public EventSource createEventSource(EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers) { + public EventSource createEventSource(final LDConfig config, EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers) { EventSource.Builder builder = new EventSource.Builder(handler, streamUri) - .client(config.httpClient.newBuilder() - .retryOnConnectionFailure(true) - .connectionPool(new ConnectionPool(1, 1, TimeUnit.SECONDS)) - .build() - ) + .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { + public void configure(OkHttpClient.Builder builder) { + if (config.sslSocketFactory != null) { + builder.sslSocketFactory(config.sslSocketFactory, config.trustManager); + } + } + }) .connectionErrorHandler(errorHandler) .headers(headers) .reconnectTimeMs(config.reconnectTimeMs) diff --git a/src/test/java/EliTest.java b/src/test/java/EliTest.java new file mode 100644 index 000000000..80da2818d --- /dev/null +++ b/src/test/java/EliTest.java @@ -0,0 +1,64 @@ +import com.launchdarkly.client.LDClient; +import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.LDUser; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import static java.util.Collections.singletonList; + +public class EliTest { + + public static void main(String... args) throws Exception { + TrustManagerFactory trustManagerFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + + KeyStore truststore = KeyStore.getInstance("pkcs12"); + trustManagerFactory.init(truststore); + TrustManager[] tms = trustManagerFactory.getTrustManagers(); + X509TrustManager trustManager = (X509TrustManager) tms[0]; + + X509TrustManager dubiousManager = new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { + throw new CertificateException("sorry"); + } + + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + throw new CertificateException("sorry"); + } + }; + + LDConfig config = new LDConfig.Builder() + .stream(false) + .sslSocketFactory(SSLContext.getDefault().getSocketFactory(), dubiousManager) + .build(); + + LDClient client = new LDClient("YOUR_SDK_KEY", config); + + LDUser user = new LDUser.Builder("bob@example.com").firstName("Bob").lastName("Loblaw") + .customString("groups", singletonList("beta_testers")).build(); + + boolean showFeature = client.boolVariation("YOUR_FEATURE_KEY", user, false); + + if (showFeature) { + System.out.println("Showing your feature"); + } else { + System.out.println("Not showing your feature"); + } + + client.flush(); + client.close(); + } +} diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 03cba9c09..7f3ed22c9 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -8,21 +8,20 @@ import com.launchdarkly.client.DefaultEventProcessor.EventDispatcher; import org.hamcrest.Matcher; -import org.junit.After; -import org.junit.Before; import org.junit.Test; import java.util.Date; import java.util.concurrent.TimeUnit; +import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; 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.contains; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasItem; -import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; @@ -41,249 +40,282 @@ public class DefaultEventProcessorTest { private static final JsonElement filteredUserJson = gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); - private final LDConfig.Builder configBuilder = new LDConfig.Builder(); - private final MockWebServer server = new MockWebServer(); - private DefaultEventProcessor ep; + // Note that all of these events depend on the fact that DefaultEventProcessor does a synchronous + // flush when it is closed; in this case, it's closed implicitly by the try-with-resources block. - @Before - public void setup() throws Exception { - server.start(); - configBuilder.eventsURI(server.url("/").uri()); - } - - @After - public void teardown() throws Exception { - if (ep != null) { - ep.close(); - } - server.shutdown(); - } - - @SuppressWarnings("unchecked") @Test public void identifyEventIsQueued() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isIdentifyEvent(e, userJson))); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIdentifyEvent(e, userJson) + )); + } } - @SuppressWarnings("unchecked") @Test public void userIsFilteredInIdentifyEvent() throws Exception { - configBuilder.allAttributesPrivate(true); - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isIdentifyEvent(e, filteredUserJson))); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + LDConfig config = baseConfig(server).allAttributesPrivate(true).build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + ep.sendEvent(e); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIdentifyEvent(e, filteredUserJson) + )); + } } @SuppressWarnings("unchecked") @Test 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, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(fe); + } - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe, userJson), - isFeatureEvent(fe, flag, false, null), - isSummaryEvent() - )); + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null), + isSummaryEvent() + )); + } } @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { - configBuilder.allAttributesPrivate(true); - 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, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + LDConfig config = baseConfig(server).allAttributesPrivate(true).build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + ep.sendEvent(fe); + } - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe, filteredUserJson), - isFeatureEvent(fe, flag, false, null), - isSummaryEvent() - )); + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, filteredUserJson), + isFeatureEvent(fe, flag, false, null), + isSummaryEvent() + )); + } } @SuppressWarnings("unchecked") @Test public void featureEventCanContainInlineUser() throws Exception { - configBuilder.inlineUsersInEvents(true); - 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, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isFeatureEvent(fe, flag, false, userJson), - isSummaryEvent() - )); + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + ep.sendEvent(fe); + } + + assertThat(getEventsFromLastRequest(server), contains( + isFeatureEvent(fe, flag, false, userJson), + isSummaryEvent() + )); + } } @SuppressWarnings("unchecked") @Test public void userIsFilteredInFeatureEvent() throws Exception { - configBuilder.inlineUsersInEvents(true).allAttributesPrivate(true); - 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, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); - - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isFeatureEvent(fe, flag, false, filteredUserJson), - isSummaryEvent() - )); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + LDConfig config = baseConfig(server).inlineUsersInEvents(true).allAttributesPrivate(true).build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + ep.sendEvent(fe); + } + + assertThat(getEventsFromLastRequest(server), contains( + isFeatureEvent(fe, flag, false, filteredUserJson), + isSummaryEvent() + )); + } } @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() - )); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(fe); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null, reason), + isSummaryEvent() + )); + } } @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { - configBuilder.inlineUsersInEvents(true); - 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, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); - - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe, userJson), - isSummaryEvent() - )); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + ep.sendEvent(fe); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, userJson), + isSummaryEvent() + )); + } } @SuppressWarnings("unchecked") @Test public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(fe); + } - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe, userJson), - isFeatureEvent(fe, flag, true, userJson), - isSummaryEvent() - )); + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, true, userJson), + isSummaryEvent() + )); + } } @SuppressWarnings("unchecked") @Test public void eventCanBeBothTrackedAndDebugged() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); - - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe, userJson), - isFeatureEvent(fe, flag, false, null), - isFeatureEvent(fe, flag, true, userJson), - isSummaryEvent() - )); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(fe); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null), + isFeatureEvent(fe, flag, true, userJson), + isSummaryEvent() + )); + } } @SuppressWarnings("unchecked") @Test public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); - // Pick a server time that is somewhat behind the client time long serverTime = System.currentTimeMillis() - 20000; + MockResponse resp1 = addDateHeader(eventsSuccessResponse(), serverTime); + MockResponse resp2 = eventsSuccessResponse(); - // Send and flush an event we don't care about, just to set the last server time - ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); - flushAndGetEvents(addDateHeader(new MockResponse(), serverTime)); - - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the server time, but in the past compared to the client. long debugUntil = serverTime + 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); - - // Should get a summary event only, not a full feature event - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) - )); + + try (MockWebServer server = makeStartedServer(resp1, resp2)) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); + ep.flush(); + ep.waitUntilInactive(); // this ensures that it has received the server response (resp1) + server.takeRequest(); // discard the first request + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the server time, but in the past compared to the client. + ep.sendEvent(fe); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, userJson), + isSummaryEvent(fe.creationDate, fe.creationDate) + )); + } } @SuppressWarnings("unchecked") @Test public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); - // Pick a server time that is somewhat ahead of the client time long serverTime = System.currentTimeMillis() + 20000; + MockResponse resp1 = addDateHeader(eventsSuccessResponse(), serverTime); + MockResponse resp2 = eventsSuccessResponse(); - // Send and flush an event we don't care about, just to set the last server time - ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); - flushAndGetEvents(addDateHeader(new MockResponse(), serverTime)); - - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the client time, but in the past compared to the server. long debugUntil = serverTime - 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, new JsonPrimitive("value")), null); - ep.sendEvent(fe); - - // Should get a summary event only, not a full feature event - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) - )); + + try (MockWebServer server = makeStartedServer(resp1, resp2)) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + + // Send and flush an event we don't care about, just to set the last server time + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); + ep.flush(); + ep.waitUntilInactive(); // this ensures that it has received the server response (resp1) + server.takeRequest(); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the client time, but in the past compared to the server. + ep.sendEvent(fe); + } + + // Should get a summary event only, not a full feature event + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe, userJson), + isSummaryEvent(fe.creationDate, fe.creationDate) + )); + } } @SuppressWarnings("unchecked") @Test public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).trackEvents(true).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); JsonElement value = new JsonPrimitive("value"); @@ -291,22 +323,25 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except simpleEvaluation(1, value), null); Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, simpleEvaluation(1, value), null); - ep.sendEvent(fe1); - ep.sendEvent(fe2); - - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe1, userJson), - isFeatureEvent(fe1, flag1, false, null), - isFeatureEvent(fe2, flag2, false, null), - isSummaryEvent(fe1.creationDate, fe2.creationDate) - )); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(fe1); + ep.sendEvent(fe2); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe1, userJson), + isFeatureEvent(fe1, flag1, false, null), + isFeatureEvent(fe2, flag2, false, null), + isSummaryEvent(fe1.creationDate, fe2.creationDate) + )); + } } @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).build(); JsonElement value = new JsonPrimitive("value"); @@ -316,111 +351,128 @@ public void nonTrackedEventsAreSummarized() throws Exception { simpleEvaluation(2, value), default1); Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, simpleEvaluation(2, value), default2); - ep.sendEvent(fe1); - ep.sendEvent(fe2); - - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(fe1, userJson), - allOf( - isSummaryEvent(fe1.creationDate, fe2.creationDate), - hasSummaryFlag(flag1.getKey(), default1, - hasItem(isSummaryEventCounter(flag1, 2, value, 1))), - hasSummaryFlag(flag2.getKey(), default2, - hasItem(isSummaryEventCounter(flag2, 2, value, 1))) - ) - )); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(fe1); + ep.sendEvent(fe2); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(fe1, userJson), + allOf( + isSummaryEvent(fe1.creationDate, fe2.creationDate), + hasSummaryFlag(flag1.getKey(), default1, + contains(isSummaryEventCounter(flag1, 2, value, 1))), + hasSummaryFlag(flag2.getKey(), default2, + contains(isSummaryEventCounter(flag2, 2, value, 1))) + ) + )); + } } @SuppressWarnings("unchecked") @Test public void customEventIsQueuedWithUser() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data); - ep.sendEvent(ce); - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems( - isIndexEvent(ce, userJson), - isCustomEvent(ce, null) - )); + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(ce); + } + + assertThat(getEventsFromLastRequest(server), contains( + isIndexEvent(ce, userJson), + isCustomEvent(ce, null) + )); + } } - @SuppressWarnings("unchecked") @Test public void customEventCanContainInlineUser() throws Exception { - configBuilder.inlineUsersInEvents(true); - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data); - ep.sendEvent(ce); - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isCustomEvent(ce, userJson))); + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + ep.sendEvent(ce); + } + + assertThat(getEventsFromLastRequest(server), contains(isCustomEvent(ce, userJson))); + } } - @SuppressWarnings("unchecked") @Test public void userIsFilteredInCustomEvent() throws Exception { - configBuilder.inlineUsersInEvents(true).allAttributesPrivate(true); - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); JsonObject data = new JsonObject(); data.addProperty("thing", "stuff"); Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data); - ep.sendEvent(ce); - JsonArray output = flushAndGetEvents(new MockResponse()); - assertThat(output, hasItems(isCustomEvent(ce, filteredUserJson))); + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + LDConfig config = baseConfig(server).inlineUsersInEvents(true).allAttributesPrivate(true).build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + ep.sendEvent(ce); + } + + assertThat(getEventsFromLastRequest(server), contains(isCustomEvent(ce, filteredUserJson))); + } } - @SuppressWarnings("unchecked") @Test public void closingEventProcessorForcesSynchronousFlush() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - - server.enqueue(new MockResponse()); - ep.close(); - JsonArray output = getEventsFromLastRequest(); - assertThat(output, hasItems(isIdentifyEvent(e, userJson))); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + } + + assertThat(getEventsFromLastRequest(server), contains(isIdentifyEvent(e, userJson))); + } } @Test public void nothingIsSentIfThereAreNoEvents() throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); - ep.close(); + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build()); + ep.close(); - assertEquals(0, server.getRequestCount()); + assertEquals(0, server.getRequestCount()); + } } @Test public void sdkKeyIsSent() 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("Authorization"), equalTo(SDK_KEY)); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + } + + RecordedRequest req = server.takeRequest(); + 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")); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + } + + RecordedRequest req = server.takeRequest(); + assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("3")); + } } @Test @@ -458,52 +510,94 @@ public void http500ErrorIsRecoverable() throws Exception { @Test public void flushIsRetriedOnceAfter5xxError() throws Exception { } + + @Test + public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { + LDConfig config = new LDConfig.Builder() + .eventsURI(serverWithCert.uri()) + .build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config)) { + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + ep.sendEvent(e); + + ep.flush(); + ep.waitUntilInactive(); + } + + assertEquals(0, serverWithCert.server.getRequestCount()); + } + } + + @Test + public void httpClientCanUseCustomTlsConfig() throws Exception { + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { + LDConfig config = new LDConfig.Builder() + .eventsURI(serverWithCert.uri()) + .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .build(); + + try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config)) { + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + ep.sendEvent(e); + + ep.flush(); + ep.waitUntilInactive(); + } + + assertEquals(1, serverWithCert.server.getRequestCount()); + } + } private void testUnrecoverableHttpError(int status) throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - flushAndGetEvents(new MockResponse().setResponseCode(status)); - - ep.sendEvent(e); - ep.flush(); - ep.waitUntilInactive(); - RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, nullValue(RecordedRequest.class)); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + } + + RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, notNullValue(RecordedRequest.class)); // this was the initial request that received the error + + // it does not retry after this type of error, so there are no more requests + assertThat(server.takeRequest(0, TimeUnit.SECONDS), nullValue(RecordedRequest.class)); + } } private void testRecoverableHttpError(int status) throws Exception { - ep = new DefaultEventProcessor(SDK_KEY, configBuilder.build()); - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - - server.enqueue(new MockResponse().setResponseCode(status)); - server.enqueue(new MockResponse().setResponseCode(status)); - server.enqueue(new MockResponse()); - // need two responses because flush will be retried one time - - ep.flush(); - ep.waitUntilInactive(); - RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, notNullValue(RecordedRequest.class)); - req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, notNullValue(RecordedRequest.class)); - req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, nullValue(RecordedRequest.class)); // only 2 requests total + MockResponse errorResponse = new MockResponse().setResponseCode(status); + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + + // send two errors in a row, because the flush will be retried one time + try (MockWebServer server = makeStartedServer(errorResponse, errorResponse, eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + } + + RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, notNullValue(RecordedRequest.class)); + req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, notNullValue(RecordedRequest.class)); + req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, nullValue(RecordedRequest.class)); // only 2 requests total + } + } + + private LDConfig.Builder baseConfig(MockWebServer server) { + return new LDConfig.Builder().eventsURI(server.url("/").uri()); } + private MockResponse eventsSuccessResponse() { + return new MockResponse().setResponseCode(202); + } + private MockResponse addDateHeader(MockResponse response, long timestamp) { return response.addHeader("Date", EventDispatcher.HTTP_DATE_FORMAT.format(new Date(timestamp))); } - private JsonArray flushAndGetEvents(MockResponse response) throws Exception { - server.enqueue(response); - ep.flush(); - ep.waitUntilInactive(); - return getEventsFromLastRequest(); - } - - private JsonArray getEventsFromLastRequest() throws Exception { + private JsonArray getEventsFromLastRequest(MockWebServer server) throws Exception { RecordedRequest req = server.takeRequest(0, TimeUnit.MILLISECONDS); assertNotNull(req); return gson.fromJson(req.getBody().readUtf8(), JsonElement.class).getAsJsonArray(); @@ -573,7 +667,7 @@ private Matcher isSummaryEvent(long startDate, long endDate) { ); } - private Matcher hasSummaryFlag(String key, JsonElement defaultVal, Matcher> counters) { + private Matcher hasSummaryFlag(String key, JsonElement defaultVal, Matcher> counters) { return hasJsonProperty("features", hasJsonProperty(key, allOf( hasJsonProperty("default", defaultVal), diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index f179a93dc..c3de17556 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -5,10 +5,15 @@ import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLHandshakeException; + import static com.launchdarkly.client.TestHttpUtil.baseConfig; +import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.client.TestHttpUtil.jsonResponse; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -26,9 +31,7 @@ public class FeatureRequestorTest { @Test public void requestAllData() throws Exception { - MockResponse resp = new MockResponse(); - resp.setHeader("Content-Type", "application/json"); - resp.setBody(allDataJson); + MockResponse resp = jsonResponse(allDataJson); try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); @@ -51,9 +54,7 @@ public void requestAllData() throws Exception { @Test public void requestFlag() throws Exception { - MockResponse resp = new MockResponse(); - resp.setHeader("Content-Type", "application/json"); - resp.setBody(flag1Json); + MockResponse resp = jsonResponse(flag1Json); try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); @@ -70,9 +71,7 @@ public void requestFlag() throws Exception { @Test public void requestSegment() throws Exception { - MockResponse resp = new MockResponse(); - resp.setHeader("Content-Type", "application/json"); - resp.setBody(segment1Json); + MockResponse resp = jsonResponse(segment1Json); try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); @@ -112,7 +111,7 @@ public void requestSegmentNotFound() throws Exception { try { r.getSegment(segment1Key); - Assert.fail("expected exception"); + fail("expected exception"); } catch (HttpErrorException e) { assertEquals(404, e.getStatus()); } @@ -121,11 +120,9 @@ public void requestSegmentNotFound() throws Exception { @Test public void requestsAreCached() throws Exception { - MockResponse cacheableResp = new MockResponse(); - cacheableResp.setHeader("Content-Type", "application/json"); - cacheableResp.setHeader("ETag", "aaa"); - cacheableResp.setHeader("Cache-Control", "max-age=1000"); - cacheableResp.setBody(flag1Json); + MockResponse cacheableResp = jsonResponse(flag1Json) + .setHeader("ETag", "aaa") + .setHeader("Cache-Control", "max-age=1000"); try (MockWebServer server = TestHttpUtil.makeStartedServer(cacheableResp)) { FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); @@ -144,6 +141,39 @@ public void requestsAreCached() throws Exception { } } + @Test + public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { + MockResponse resp = jsonResponse(flag1Json); + + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { + FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(serverWithCert.server).build()); + + try { + r.getFlag(flag1Key); + fail("expected exception"); + } catch (SSLHandshakeException e) { + } + + assertEquals(0, serverWithCert.server.getRequestCount()); + } + } + + @Test + public void httpClientCanUseCustomTlsConfig() throws Exception { + MockResponse resp = jsonResponse(flag1Json); + + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { + LDConfig config = basePollingConfig(serverWithCert.server) + .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .build(); + + FeatureRequestor r = new FeatureRequestor(sdkKey, config); + + FeatureFlag flag = r.getFlag(flag1Key); + verifyFlag(flag, flag1Key); + } + } + private LDConfig.Builder basePollingConfig(MockWebServer server) { return baseConfig(server) .stream(false); diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index 135c2b40f..1e286048a 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -4,23 +4,17 @@ import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; -import org.junit.Assert; import org.junit.Test; -import java.util.concurrent.TimeUnit; - import static com.launchdarkly.client.TestHttpUtil.baseConfig; +import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.client.TestHttpUtil.jsonResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; -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 okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import okhttp3.mockwebserver.SocketPolicy; public class LDClientEndToEndTest { private static final Gson gson = new Gson(); @@ -33,9 +27,7 @@ public class LDClientEndToEndTest { @Test public void clientStartsInPollingMode() throws Exception { - MockResponse resp = new MockResponse() - .setHeader("Content-Type", "application/json") - .setBody(makeAllDataJson()); + MockResponse resp = jsonResponse(makeAllDataJson()); try (MockWebServer server = makeStartedServer(resp)) { LDConfig config = baseConfig(server) @@ -66,16 +58,30 @@ public void clientFailsInPollingModeWith401Error() throws Exception { } } } - + @Test - public void clientStartsInStreamingMode() throws Exception { - String eventData = "event: put\n" + - "data: {\"data\":" + makeAllDataJson() + "}\n\n"; + public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { + MockResponse resp = jsonResponse(makeAllDataJson()); - MockResponse resp = new MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setChunkedBody(eventData, 1000) - .setSocketPolicy(SocketPolicy.KEEP_OPEN); + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { + LDConfig config = baseConfig(serverWithCert.server) + .stream(false) + .sendEvents(false) + .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.initialized()); + assertTrue(client.boolVariation(flagKey, user, false)); + } + } + } + + @Test + public void clientStartsInStreamingMode() throws Exception { + String streamData = "event: put\n" + + "data: {\"data\":" + makeAllDataJson() + "}\n\n"; + MockResponse resp = TestHttpUtil.eventStreamResponse(streamData); try (MockWebServer server = makeStartedServer(resp)) { LDConfig config = baseConfig(server) @@ -104,7 +110,26 @@ public void clientFailsInStreamingModeWith401Error() throws Exception { } } } - + + @Test + public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { + String streamData = "event: put\n" + + "data: {\"data\":" + makeAllDataJson() + "}\n\n"; + MockResponse resp = TestHttpUtil.eventStreamResponse(streamData); + + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { + LDConfig config = baseConfig(serverWithCert.server) + .sendEvents(false) + .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.initialized()); + assertTrue(client.boolVariation(flagKey, user, false)); + } + } + } + public String makeAllDataJson() { JsonObject flagsData = new JsonObject(); flagsData.add(flagKey, gson.toJsonTree(flag)); diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index ee29937c9..d3fa4270e 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -13,21 +13,28 @@ import java.io.IOException; import java.net.URI; import java.util.Collections; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLHandshakeException; + +import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.expect; 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 static org.junit.Assert.fail; import okhttp3.Headers; +import okhttp3.mockwebserver.MockResponse; public class StreamProcessorTest extends EasyMockSupport { @@ -39,7 +46,10 @@ public class StreamProcessorTest extends EasyMockSupport { private static final String SEGMENT1_KEY = "segment1"; private static final int SEGMENT1_VERSION = 22; private static final Segment SEGMENT = new Segment.Builder(SEGMENT1_KEY).version(SEGMENT1_VERSION).build(); - + private static final String STREAM_RESPONSE_WITH_EMPTY_DATA = + "event: put\n" + + "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; + private InMemoryFeatureStore featureStore; private LDConfig.Builder configBuilder; private FeatureRequestor mockRequestor; @@ -312,6 +322,64 @@ public void http429ErrorIsRecoverable() throws Exception { public void http500ErrorIsRecoverable() throws Exception { testRecoverableHttpError(500); } + + // There are already end-to-end tests against an HTTP server in okhttp-eventsource, so we won't retest the + // basic stream mechanism in detail. However, we do want to make sure that the LDConfig options are correctly + // applied to the EventSource for things like TLS configuration. + + @Test + public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { + final ConnectionErrorSink errorSink = new ConnectionErrorSink(); + try (TestHttpUtil.ServerWithCert server = new TestHttpUtil.ServerWithCert()) { + server.server.enqueue(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA)); + + LDConfig config = new LDConfig.Builder() + .streamURI(server.uri()) + .build(); + + try (StreamProcessor sp = new StreamProcessor("sdk-key", config, + mockRequestor, featureStore, null)) { + sp.connectionErrorHandler = errorSink; + Future ready = sp.start(); + ready.get(); + + Throwable error = errorSink.errors.peek(); + assertNotNull(error); + assertEquals(SSLHandshakeException.class, error.getClass()); + } + } + } + + @Test + public void httpClientCanUseCustomTlsConfig() throws Exception { + final ConnectionErrorSink errorSink = new ConnectionErrorSink(); + try (TestHttpUtil.ServerWithCert server = new TestHttpUtil.ServerWithCert()) { + server.server.enqueue(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA)); + + LDConfig config = new LDConfig.Builder() + .streamURI(server.uri()) + .sslSocketFactory(server.sslClient.socketFactory, server.sslClient.trustManager) // allows us to trust the self-signed cert + .build(); + + try (StreamProcessor sp = new StreamProcessor("sdk-key", config, + mockRequestor, featureStore, null)) { + sp.connectionErrorHandler = errorSink; + Future ready = sp.start(); + ready.get(); + + assertNull(errorSink.errors.peek()); + } + } + } + + static class ConnectionErrorSink implements ConnectionErrorHandler { + final BlockingQueue errors = new LinkedBlockingQueue<>(); + + public Action onConnectionError(Throwable t) { + errors.add(t); + return Action.SHUTDOWN; + } + } private void testUnrecoverableHttpError(int status) throws Exception { UnsuccessfulResponseException e = new UnsuccessfulResponseException(status); @@ -382,7 +450,7 @@ private void assertSegmentInStore(Segment segment) { } private class StubEventSourceCreator implements StreamProcessor.EventSourceCreator { - public EventSource createEventSource(EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, + public EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers) { StreamProcessorTest.this.eventHandler = handler; StreamProcessorTest.this.actualStreamUri = streamUri; diff --git a/src/test/java/com/launchdarkly/client/TestHttpCertificates.java b/src/test/java/com/launchdarkly/client/TestHttpCertificates.java new file mode 100644 index 000000000..bf854745c --- /dev/null +++ b/src/test/java/com/launchdarkly/client/TestHttpCertificates.java @@ -0,0 +1,164 @@ +package com.launchdarkly.client; + +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import javax.security.auth.x500.X500Principal; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.SubjectKeyIdentifier; +import org.bouncycastle.asn1.x509.X509Extensions; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.x509.X509V3CertificateGenerator; + +import static okhttp3.internal.Util.verifyAsIpAddress; + +/** + * A certificate and its private key. This can be used on the server side by HTTPS servers, or on + * the client side to verify those HTTPS servers. A held certificate can also be used to sign other + * held certificates, as done in practice by certificate authorities. + */ +public final class TestHttpCertificates { + public final X509Certificate certificate; + public final KeyPair keyPair; + + public TestHttpCertificates(X509Certificate certificate, KeyPair keyPair) { + this.certificate = certificate; + this.keyPair = keyPair; + } + + public static final class Builder { + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private final long duration = 1000L * 60 * 60 * 24; // One day. + private String hostname; + private List altNames = new ArrayList<>(); + private String serialNumber = "1"; + private KeyPair keyPair; + private TestHttpCertificates issuedBy; + private int maxIntermediateCas; + + public Builder serialNumber(String serialNumber) { + this.serialNumber = serialNumber; + return this; + } + + /** + * Set this certificate's name. Typically this is the URL hostname for TLS certificates. This is + * the CN (common name) in the certificate. Will be a random string if no value is provided. + */ + public Builder commonName(String hostname) { + this.hostname = hostname; + return this; + } + + public Builder keyPair(KeyPair keyPair) { + this.keyPair = keyPair; + return this; + } + + /** + * Set the certificate that signs this certificate. If unset, a self-signed certificate will be + * generated. + */ + public Builder issuedBy(TestHttpCertificates signedBy) { + this.issuedBy = signedBy; + return this; + } + + /** + * Set this certificate to be a certificate authority, with up to {@code maxIntermediateCas} + * intermediate certificate authorities beneath it. + */ + public Builder ca(int maxIntermediateCas) { + this.maxIntermediateCas = maxIntermediateCas; + return this; + } + + /** + * Adds a subject alternative name to the certificate. This is usually a hostname or IP address. + * If no subject alternative names are added that extension will not be used. + */ + public Builder subjectAlternativeName(String altName) { + altNames.add(altName); + return this; + } + + public TestHttpCertificates build() throws GeneralSecurityException { + // Subject, public & private keys for this certificate. + KeyPair heldKeyPair = keyPair != null + ? keyPair + : generateKeyPair(); + X500Principal subject = hostname != null + ? new X500Principal("CN=" + hostname) + : new X500Principal("CN=" + UUID.randomUUID()); + + // Subject, public & private keys for this certificate's signer. It may be self signed! + KeyPair signedByKeyPair; + X500Principal signedByPrincipal; + if (issuedBy != null) { + signedByKeyPair = issuedBy.keyPair; + signedByPrincipal = issuedBy.certificate.getSubjectX500Principal(); + } else { + signedByKeyPair = heldKeyPair; + signedByPrincipal = subject; + } + + // Generate & sign the certificate. + long now = System.currentTimeMillis(); + X509V3CertificateGenerator generator = new X509V3CertificateGenerator(); + generator.setSerialNumber(new BigInteger(serialNumber)); + generator.setIssuerDN(signedByPrincipal); + generator.setNotBefore(new Date(now)); + generator.setNotAfter(new Date(now + duration)); + generator.setSubjectDN(subject); + generator.setPublicKey(heldKeyPair.getPublic()); + generator.setSignatureAlgorithm("SHA256WithRSAEncryption"); + + if (maxIntermediateCas > 0) { + generator.addExtension(X509Extensions.BasicConstraints, true, + new BasicConstraints(maxIntermediateCas)); + } + + generator.addExtension(X509Extensions.BasicConstraints, true, new BasicConstraints(true)); + generator.addExtension(X509Extensions.SubjectKeyIdentifier, true, new SubjectKeyIdentifier("z".getBytes())); + generator.addExtension(X509Extensions.AuthorityKeyIdentifier, true, new AuthorityKeyIdentifier("x".getBytes())); + + if (!altNames.isEmpty()) { + ASN1Encodable[] encodableAltNames = new ASN1Encodable[altNames.size()]; + for (int i = 0, size = altNames.size(); i < size; i++) { + String altName = altNames.get(i); + int tag = verifyAsIpAddress(altName) + ? GeneralName.iPAddress + : GeneralName.dNSName; + encodableAltNames[i] = new GeneralName(tag, altName); + } + generator.addExtension(X509Extensions.SubjectAlternativeName, true, + new DERSequence(encodableAltNames)); + } + + X509Certificate certificate = generator.generateX509Certificate( + signedByKeyPair.getPrivate(), "BC"); + return new TestHttpCertificates(certificate, heldKeyPair); + } + + public KeyPair generateKeyPair() throws GeneralSecurityException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(1024, new SecureRandom()); + return keyPairGenerator.generateKeyPair(); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 5c1f6371d..0226cbae3 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -21,6 +21,15 @@ static MockWebServer makeStartedServer(MockResponse... responses) throws IOExcep return server; } + static ServerWithCert httpsServerWithSelfSignedCert(MockResponse... responses) throws IOException, GeneralSecurityException { + ServerWithCert ret = new ServerWithCert(); + for (MockResponse r: responses) { + ret.server.enqueue(r); + } + ret.server.start(); + return ret; + } + static LDConfig.Builder baseConfig(MockWebServer server) { URI uri = server.url("").uri(); return new LDConfig.Builder() @@ -29,12 +38,24 @@ static LDConfig.Builder baseConfig(MockWebServer server) { .eventsURI(uri); } - static class HttpsServerWithSelfSignedCert implements Closeable { + static MockResponse jsonResponse(String body) { + return new MockResponse() + .setHeader("Content-Type", "application/json") + .setBody(body); + } + + static MockResponse eventStreamResponse(String data) { + return new MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setChunkedBody(data, 1000); + } + + static class ServerWithCert implements Closeable { final MockWebServer server; final HeldCertificate cert; final SslClient sslClient; - public HttpsServerWithSelfSignedCert() throws IOException, GeneralSecurityException { + public ServerWithCert() throws IOException, GeneralSecurityException { cert = new HeldCertificate.Builder() .serialNumber("1") .commonName(InetAddress.getByName("localhost").getCanonicalHostName()) @@ -48,8 +69,6 @@ public HttpsServerWithSelfSignedCert() throws IOException, GeneralSecurityExcept server = new MockWebServer(); server.useHttps(sslClient.socketFactory, false); - - server.start(); } public URI uri() { diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 422db127d..d791fc939 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -241,7 +241,7 @@ protected boolean matchesSafely(JsonElement item, Description mismatchDescriptio }; } - public static Matcher isJsonArray(final Matcher> matcher) { + public static Matcher isJsonArray(final Matcher> matcher) { return new TypeSafeDiagnosingMatcher() { @Override public void describeTo(Description description) { From b9aa701b13898d23c91fa3a8de276ae2fc65781b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Aug 2019 14:08:44 -0700 Subject: [PATCH 165/327] rm test code --- src/test/java/EliTest.java | 64 -------------------------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/test/java/EliTest.java diff --git a/src/test/java/EliTest.java b/src/test/java/EliTest.java deleted file mode 100644 index 80da2818d..000000000 --- a/src/test/java/EliTest.java +++ /dev/null @@ -1,64 +0,0 @@ -import com.launchdarkly.client.LDClient; -import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.LDUser; - -import java.io.IOException; -import java.security.KeyStore; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; - -import static java.util.Collections.singletonList; - -public class EliTest { - - public static void main(String... args) throws Exception { - TrustManagerFactory trustManagerFactory = TrustManagerFactory - .getInstance(TrustManagerFactory.getDefaultAlgorithm()); - - KeyStore truststore = KeyStore.getInstance("pkcs12"); - trustManagerFactory.init(truststore); - TrustManager[] tms = trustManagerFactory.getTrustManagers(); - X509TrustManager trustManager = (X509TrustManager) tms[0]; - - X509TrustManager dubiousManager = new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - - public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { - throw new CertificateException("sorry"); - } - - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - throw new CertificateException("sorry"); - } - }; - - LDConfig config = new LDConfig.Builder() - .stream(false) - .sslSocketFactory(SSLContext.getDefault().getSocketFactory(), dubiousManager) - .build(); - - LDClient client = new LDClient("YOUR_SDK_KEY", config); - - LDUser user = new LDUser.Builder("bob@example.com").firstName("Bob").lastName("Loblaw") - .customString("groups", singletonList("beta_testers")).build(); - - boolean showFeature = client.boolVariation("YOUR_FEATURE_KEY", user, false); - - if (showFeature) { - System.out.println("Showing your feature"); - } else { - System.out.println("Not showing your feature"); - } - - client.flush(); - client.close(); - } -} From a6209c0dfcb4a46ce1132f81ce7cb9264c8d9504 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Aug 2019 14:14:57 -0700 Subject: [PATCH 166/327] rm unused --- .../client/TestHttpCertificates.java | 164 ------------------ 1 file changed, 164 deletions(-) delete mode 100644 src/test/java/com/launchdarkly/client/TestHttpCertificates.java diff --git a/src/test/java/com/launchdarkly/client/TestHttpCertificates.java b/src/test/java/com/launchdarkly/client/TestHttpCertificates.java deleted file mode 100644 index bf854745c..000000000 --- a/src/test/java/com/launchdarkly/client/TestHttpCertificates.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.launchdarkly.client; - -import java.math.BigInteger; -import java.security.GeneralSecurityException; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.SecureRandom; -import java.security.Security; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.UUID; -import javax.security.auth.x500.X500Principal; -import org.bouncycastle.asn1.ASN1Encodable; -import org.bouncycastle.asn1.DERSequence; -import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; -import org.bouncycastle.asn1.x509.BasicConstraints; -import org.bouncycastle.asn1.x509.GeneralName; -import org.bouncycastle.asn1.x509.SubjectKeyIdentifier; -import org.bouncycastle.asn1.x509.X509Extensions; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.x509.X509V3CertificateGenerator; - -import static okhttp3.internal.Util.verifyAsIpAddress; - -/** - * A certificate and its private key. This can be used on the server side by HTTPS servers, or on - * the client side to verify those HTTPS servers. A held certificate can also be used to sign other - * held certificates, as done in practice by certificate authorities. - */ -public final class TestHttpCertificates { - public final X509Certificate certificate; - public final KeyPair keyPair; - - public TestHttpCertificates(X509Certificate certificate, KeyPair keyPair) { - this.certificate = certificate; - this.keyPair = keyPair; - } - - public static final class Builder { - static { - Security.addProvider(new BouncyCastleProvider()); - } - - private final long duration = 1000L * 60 * 60 * 24; // One day. - private String hostname; - private List altNames = new ArrayList<>(); - private String serialNumber = "1"; - private KeyPair keyPair; - private TestHttpCertificates issuedBy; - private int maxIntermediateCas; - - public Builder serialNumber(String serialNumber) { - this.serialNumber = serialNumber; - return this; - } - - /** - * Set this certificate's name. Typically this is the URL hostname for TLS certificates. This is - * the CN (common name) in the certificate. Will be a random string if no value is provided. - */ - public Builder commonName(String hostname) { - this.hostname = hostname; - return this; - } - - public Builder keyPair(KeyPair keyPair) { - this.keyPair = keyPair; - return this; - } - - /** - * Set the certificate that signs this certificate. If unset, a self-signed certificate will be - * generated. - */ - public Builder issuedBy(TestHttpCertificates signedBy) { - this.issuedBy = signedBy; - return this; - } - - /** - * Set this certificate to be a certificate authority, with up to {@code maxIntermediateCas} - * intermediate certificate authorities beneath it. - */ - public Builder ca(int maxIntermediateCas) { - this.maxIntermediateCas = maxIntermediateCas; - return this; - } - - /** - * Adds a subject alternative name to the certificate. This is usually a hostname or IP address. - * If no subject alternative names are added that extension will not be used. - */ - public Builder subjectAlternativeName(String altName) { - altNames.add(altName); - return this; - } - - public TestHttpCertificates build() throws GeneralSecurityException { - // Subject, public & private keys for this certificate. - KeyPair heldKeyPair = keyPair != null - ? keyPair - : generateKeyPair(); - X500Principal subject = hostname != null - ? new X500Principal("CN=" + hostname) - : new X500Principal("CN=" + UUID.randomUUID()); - - // Subject, public & private keys for this certificate's signer. It may be self signed! - KeyPair signedByKeyPair; - X500Principal signedByPrincipal; - if (issuedBy != null) { - signedByKeyPair = issuedBy.keyPair; - signedByPrincipal = issuedBy.certificate.getSubjectX500Principal(); - } else { - signedByKeyPair = heldKeyPair; - signedByPrincipal = subject; - } - - // Generate & sign the certificate. - long now = System.currentTimeMillis(); - X509V3CertificateGenerator generator = new X509V3CertificateGenerator(); - generator.setSerialNumber(new BigInteger(serialNumber)); - generator.setIssuerDN(signedByPrincipal); - generator.setNotBefore(new Date(now)); - generator.setNotAfter(new Date(now + duration)); - generator.setSubjectDN(subject); - generator.setPublicKey(heldKeyPair.getPublic()); - generator.setSignatureAlgorithm("SHA256WithRSAEncryption"); - - if (maxIntermediateCas > 0) { - generator.addExtension(X509Extensions.BasicConstraints, true, - new BasicConstraints(maxIntermediateCas)); - } - - generator.addExtension(X509Extensions.BasicConstraints, true, new BasicConstraints(true)); - generator.addExtension(X509Extensions.SubjectKeyIdentifier, true, new SubjectKeyIdentifier("z".getBytes())); - generator.addExtension(X509Extensions.AuthorityKeyIdentifier, true, new AuthorityKeyIdentifier("x".getBytes())); - - if (!altNames.isEmpty()) { - ASN1Encodable[] encodableAltNames = new ASN1Encodable[altNames.size()]; - for (int i = 0, size = altNames.size(); i < size; i++) { - String altName = altNames.get(i); - int tag = verifyAsIpAddress(altName) - ? GeneralName.iPAddress - : GeneralName.dNSName; - encodableAltNames[i] = new GeneralName(tag, altName); - } - generator.addExtension(X509Extensions.SubjectAlternativeName, true, - new DERSequence(encodableAltNames)); - } - - X509Certificate certificate = generator.generateX509Certificate( - signedByKeyPair.getPrivate(), "BC"); - return new TestHttpCertificates(certificate, heldKeyPair); - } - - public KeyPair generateKeyPair() throws GeneralSecurityException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); - keyPairGenerator.initialize(1024, new SecureRandom()); - return keyPairGenerator.generateKeyPair(); - } - } -} \ No newline at end of file From 7cddd94878459b7a5d63e97de5c65d581b0425aa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Aug 2019 15:05:29 -0700 Subject: [PATCH 167/327] change test cert properties for Azure compatibility --- src/test/java/com/launchdarkly/client/TestHttpUtil.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 0226cbae3..8f86c7b1b 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -56,10 +56,12 @@ static class ServerWithCert implements Closeable { final SslClient sslClient; public ServerWithCert() throws IOException, GeneralSecurityException { + String hostname = InetAddress.getByName("localhost").getCanonicalHostName(); + cert = new HeldCertificate.Builder() .serialNumber("1") - .commonName(InetAddress.getByName("localhost").getCanonicalHostName()) - .subjectAlternativeName("localhost") + .commonName(hostname) + .subjectAlternativeName(hostname) .build(); sslClient = new SslClient.Builder() From b4f9054b0961aac6ecdf668f2fdda7d13c317062 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Aug 2019 16:07:01 -0700 Subject: [PATCH 168/327] add Redis builder options for password, TLS, database --- .../client/FeatureStoreCacheConfig.java | 8 +- .../com/launchdarkly/client/LDConfig.java | 2 + .../client/RedisFeatureStore.java | 37 ++++++-- .../client/RedisFeatureStoreBuilder.java | 86 +++++++++++++------ .../client/RedisFeatureStoreTest.java | 6 +- 5 files changed, 100 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 2c92bee20..9c615e4b1 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -1,7 +1,6 @@ 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; @@ -15,17 +14,16 @@ * methods {@link #disabled()} or {@link #enabled()}; then, if desired, you can use chained methods * to set other properties: * - *

    - *     new RedisFeatureStoreBuilder()
    + * 
    
    + *     Components.redisFeatureStore()
      *         .caching(
      *             FeatureStoreCacheConfig.enabled()
      *                 .ttlSeconds(30)
      *                 .staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH)
      *         )
    - * 
    + *
    * * @see RedisFeatureStoreBuilder#caching(FeatureStoreCacheConfig) - * @see CachingStoreWrapper.Builder#caching(FeatureStoreCacheConfig) * @since 4.6.0 */ public final class FeatureStoreCacheConfig { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 3044c604e..05683e8ff 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -429,6 +429,8 @@ public Builder proxyPassword(String password) { * @param sslSocketFactory the SSL socket factory * @param trustManager the trust manager * @return the builder + * + * @since 4.7.0 */ public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { this.sslSocketFactory = sslSocketFactory; diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 296dd0aa1..55091fa40 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -20,6 +20,7 @@ import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Transaction; +import redis.clients.util.JedisURIHelper; /** * An implementation of {@link FeatureStore} backed by Redis. Also @@ -86,8 +87,36 @@ public CacheStats getCacheStats() { * @param builder the configured builder to construct the store with. */ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); - JedisPool pool = new JedisPool(poolConfig, builder.uri, builder.connectTimeout, builder.socketTimeout); + // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, + // the overloads that accept a URI do not accept the other parameters we need to set, so we need + // to decompose the URI. + String host = builder.uri.getHost(); + int port = builder.uri.getPort(); + String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; + int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); + boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); + + String extra = tls ? " with TLS" : ""; + if (password != null) { + extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; + } + logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); + + JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); + JedisPool pool = new JedisPool(poolConfig, + host, + port, + builder.connectTimeout, + builder.socketTimeout, + password, + database, + null, // clientName + tls, + null, // sslSocketFactory + null, // sslParameters + null // hostnameVerifier + ); + String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? RedisFeatureStoreBuilder.DEFAULT_PREFIX : builder.prefix; @@ -102,9 +131,7 @@ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { * @deprecated Please use {@link Components#redisFeatureStore()} instead. */ public RedisFeatureStore() { - JedisPool pool = new JedisPool(new JedisPoolConfig(), "localhost"); - this.core = new Core(pool, RedisFeatureStoreBuilder.DEFAULT_PREFIX); - this.wrapper = CachingStoreWrapper.builder(this.core).build(); + this(new RedisFeatureStoreBuilder().caching(FeatureStoreCacheConfig.disabled())); } 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 cf8eab4a0..b1c3124cc 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -1,7 +1,5 @@ package com.launchdarkly.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; @@ -10,22 +8,19 @@ import java.util.concurrent.TimeUnit; /** - * This class exposes advanced configuration options for the {@link com.launchdarkly.client.RedisFeatureStore}. + * A builder for configuring the Redis-based persistent feature store. * - * A builder that helps construct {@link com.launchdarkly.client.RedisFeatureStore} objects. - * {@link RedisFeatureStoreBuilder} calls can be chained, enabling the following pattern: + * Obtain an instance of this class by calling {@link Components#redisFeatureStore()} or {@link Components#redisFeatureStore(URI)}. + * Builder calls can be chained, for example: * - *
    - * RedisFeatureStore store = new RedisFeatureStoreBuilder("host", 443, 60)
    - *      .refreshStaleValues(true)
    - *      .asyncRefresh(true)
    - *      .socketTimeout(200)
    - *      .build()
    - * 
    + *
    
    + * FeatureStore store = Components.redisFeatureStore()
    + *      .database(1)
    + *      .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
    + *      .build();
    + * 
    */ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { - private static final Logger logger = LoggerFactory.getLogger(RedisFeatureStoreBuilder.class); - /** * The default value for the Redis URI: {@code redis://localhost:6379} * @since 4.0.0 @@ -49,7 +44,9 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { String prefix = DEFAULT_PREFIX; int connectTimeout = Protocol.DEFAULT_TIMEOUT; int socketTimeout = Protocol.DEFAULT_TIMEOUT; + Integer database = null; String password = null; + boolean tls = false; FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCacheConfig, but are used by deprecated setters boolean asyncRefresh = false; @@ -90,6 +87,55 @@ 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); } + + /** + * Specifies the database number to use. + *

    + * The database number can also be specified in the Redis URI, in the form {@code redis://host:port/NUMBER}. Any + * non-null value that you set with {@lkink #database(Integer)} will override the URI. + * + * @param database the database number, or null to fall back to the URI or the default + * @return the builder + * + * @since 4.7.0 + */ + public RedisFeatureStoreBuilder database(Integer database) { + this.database = database; + return this; + } + + /** + * Specifies a password that will be sent to Redis in an AUTH command. + *

    + * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port}. Any + * password that you set with {@link #password(String)} will override the URI. + * + * @param password the password + * @return the builder + * + * @since 4.7.0 + */ + public RedisFeatureStoreBuilder password(String password) { + this.password = password; + return this; + } + + /** + * Optionally enables TLS for secure connections to Redis. + *

    + * This is equivalent to specifying a Redis URI that begins with {@code rediss:} rather than {@code redis:}. + *

    + * Note that not all Redis server distributions support TLS. + * + * @param tls true to enable TLS + * @return the builder + * + * @since 4.7.0 + */ + public RedisFeatureStoreBuilder tls(boolean tls) { + this.tls = tls; + return this; + } /** * Specifies whether local caching should be enabled and if so, sets the cache properties. Local @@ -214,23 +260,11 @@ public RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUn return this; } - /** - * Specifies a password that will be sent to Redis in an AUTH command. - * - * @param password the password - * @return the builder - */ - public RedisFeatureStoreBuilder password(String password) { - this.password = password; - return this; - } - /** * Build a {@link RedisFeatureStore} based on the currently configured builder object. * @return the {@link RedisFeatureStore} configured by this builder. */ public RedisFeatureStore build() { - logger.info("Creating RedisFeatureStore with uri: " + uri + " and prefix: " + prefix); return new RedisFeatureStore(this); } diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java index 9299bf269..af120198f 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java @@ -16,19 +16,19 @@ public RedisFeatureStoreTest(boolean cached) { @Override protected RedisFeatureStore makeStore() { - RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI); + RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI).password("foobared"); builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); return builder.build(); } @Override protected RedisFeatureStore makeStoreWithPrefix(String prefix) { - return new RedisFeatureStoreBuilder(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); + return new RedisFeatureStoreBuilder(REDIS_URI).password("foobared").caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); } @Override protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { + try (Jedis client = new Jedis(URI.create("redis://:foobared@localhost:6379"))) { client.flushDB(); } } From 450868e699271b977cbbfa734eb5c71fdf1c313d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 2 Aug 2019 16:11:49 -0700 Subject: [PATCH 169/327] revert debugging changes --- .../java/com/launchdarkly/client/RedisFeatureStoreTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java index af120198f..9299bf269 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java @@ -16,19 +16,19 @@ public RedisFeatureStoreTest(boolean cached) { @Override protected RedisFeatureStore makeStore() { - RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI).password("foobared"); + RedisFeatureStoreBuilder builder = new RedisFeatureStoreBuilder(REDIS_URI); builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); return builder.build(); } @Override protected RedisFeatureStore makeStoreWithPrefix(String prefix) { - return new RedisFeatureStoreBuilder(REDIS_URI).password("foobared").caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); + return new RedisFeatureStoreBuilder(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).build(); } @Override protected void clearAllData() { - try (Jedis client = new Jedis(URI.create("redis://:foobared@localhost:6379"))) { + try (Jedis client = new Jedis("localhost")) { client.flushDB(); } } From 1bce52ea16cf988befe4fe448cafdb9420981074 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Aug 2019 14:05:36 -0700 Subject: [PATCH 170/327] update doc comment for track() with metric --- .../java/com/launchdarkly/client/LDClientInterface.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index a218f267e..81f1564f6 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -31,13 +31,19 @@ public interface LDClientInterface extends Closeable { /** * Tracks that a user performed an event, and provides an additional numeric value for custom metrics. - * + *

    + * As of this version’s release date, the LaunchDarkly service does not support the {@code metricValue} + * parameter. As a result, calling this overload of {@code track} will not yet produce any different + * behavior from calling {@link #track(String, LDUser, JsonElement)} without a {@code metricValue}. + * Refer to the SDK reference guide for the latest status: https://docs.launchdarkly.com/docs/java-sdk-reference#section-track + * * @param eventName the name of the event * @param user the user that performed the event * @param data a JSON object containing additional data associated with the event; may be null * @param metricValue a numeric value used by the LaunchDarkly experimentation feature in numeric custom * metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be * returned as part of the custom event for Data Export. + * @since 4.8.0 */ void track(String eventName, LDUser user, JsonElement data, double metricValue); From 52868d248db2bc302cb17519b2c02976e34d5f3d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Aug 2019 14:27:52 -0700 Subject: [PATCH 171/327] inline doc link --- src/main/java/com/launchdarkly/client/LDClientInterface.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 81f1564f6..ba14494d7 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -35,7 +35,7 @@ public interface LDClientInterface extends Closeable { * As of this version’s release date, the LaunchDarkly service does not support the {@code metricValue} * parameter. As a result, calling this overload of {@code track} will not yet produce any different * behavior from calling {@link #track(String, LDUser, JsonElement)} without a {@code metricValue}. - * Refer to the SDK reference guide for the latest status: https://docs.launchdarkly.com/docs/java-sdk-reference#section-track + * Refer to the SDK reference guide for the latest status/ * * @param eventName the name of the event * @param user the user that performed the event From d47af12cbc2f408aaaee8da8365cbb7b89be5c91 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Aug 2019 14:33:28 -0700 Subject: [PATCH 172/327] avoid concurrency problem with date parser for event responses --- .../client/DefaultEventProcessor.java | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 5afb3fdec..ad3ff88d7 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -11,6 +11,7 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Random; import java.util.concurrent.ArrayBlockingQueue; @@ -178,7 +179,6 @@ 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; private final List flushWorkers; @@ -231,8 +231,8 @@ public void uncaughtException(Thread t, Throwable e) { flushWorkers = new ArrayList<>(); EventResponseListener listener = new EventResponseListener() { - public void handleResponse(Response response) { - EventDispatcher.this.handleResponse(response); + public void handleResponse(Response response, Date responseDate) { + EventDispatcher.this.handleResponse(response, responseDate); } }; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { @@ -404,14 +404,7 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } } - private void handleResponse(Response response) { - String dateStr = response.header("Date"); - if (dateStr != null) { - try { - lastKnownPastTime.set(HTTP_DATE_FORMAT.parse(dateStr).getTime()); - } catch (ParseException e) { - } - } + private void handleResponse(Response response, Date responseDate) { if (!isHttpErrorRecoverable(response.code())) { disabled.set(true); logger.error(httpErrorMessage(response.code(), "posting events", "some events were dropped")); @@ -475,7 +468,7 @@ private static final class FlushPayload { } private static interface EventResponseListener { - void handleResponse(Response response); + void handleResponse(Response response, Date responseDate); } private static final class SendEventsTask implements Runnable { @@ -487,6 +480,7 @@ private static final class SendEventsTask implements Runnable { private final AtomicBoolean stopping; private final EventOutput.Formatter formatter; private final Thread thread; + private final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); // need one instance per task because the date parser isn't thread-safe SendEventsTask(String sdkKey, LDConfig config, EventResponseListener responseListener, BlockingQueue payloadQueue, AtomicInteger activeFlushWorkersCount, @@ -563,7 +557,7 @@ private void postEvents(List eventsOut) { continue; } } - responseListener.handleResponse(response); + responseListener.handleResponse(response, getResponseDate(response)); break; } catch (IOException e) { logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: " + request.url(), e); @@ -571,5 +565,17 @@ private void postEvents(List eventsOut) { } } } + + private Date getResponseDate(Response response) { + String dateStr = response.header("Date"); + if (dateStr != null) { + try { + return httpDateFormat.parse(dateStr); + } catch (ParseException e) { + logger.warn("Received invalid Date header from events service"); + } + } + return null; + } } } From cdb18be21ee147738db30b99b1a1a558e56ffbc9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Aug 2019 14:47:34 -0700 Subject: [PATCH 173/327] revert accidental deletion, fix test --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 3 +++ .../com/launchdarkly/client/DefaultEventProcessorTest.java | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index ad3ff88d7..30fc0bdfb 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -405,6 +405,9 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } private void handleResponse(Response response, Date responseDate) { + if (responseDate != null) { + lastKnownPastTime.set(responseDate.getTime()); + } if (!isHttpErrorRecoverable(response.code())) { disabled.set(true); logger.error(httpErrorMessage(response.code(), "posting events", "some events were dropped")); diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 7f3ed22c9..9bb3d69dc 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -10,6 +10,7 @@ import org.hamcrest.Matcher; import org.junit.Test; +import java.text.SimpleDateFormat; import java.util.Date; import java.util.concurrent.TimeUnit; @@ -39,6 +40,7 @@ public class DefaultEventProcessorTest { gson.fromJson("{\"key\":\"userkey\",\"name\":\"Red\"}", JsonElement.class); private static final JsonElement filteredUserJson = gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); + private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); // Note that all of these events depend on the fact that DefaultEventProcessor does a synchronous // flush when it is closed; in this case, it's closed implicitly by the try-with-resources block. @@ -594,7 +596,7 @@ private MockResponse eventsSuccessResponse() { } private MockResponse addDateHeader(MockResponse response, long timestamp) { - return response.addHeader("Date", EventDispatcher.HTTP_DATE_FORMAT.format(new Date(timestamp))); + return response.addHeader("Date", httpDateFormat.format(new Date(timestamp))); } private JsonArray getEventsFromLastRequest(MockWebServer server) throws Exception { From 4789513a1804912aaaabe07c8690ab713a6b88ae Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 19 Aug 2019 15:38:25 -0700 Subject: [PATCH 174/327] typo --- src/main/java/com/launchdarkly/client/LDClientInterface.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index ba14494d7..9aa9d28f4 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -35,7 +35,7 @@ public interface LDClientInterface extends Closeable { * As of this version’s release date, the LaunchDarkly service does not support the {@code metricValue} * parameter. As a result, calling this overload of {@code track} will not yet produce any different * behavior from calling {@link #track(String, LDUser, JsonElement)} without a {@code metricValue}. - * Refer to the SDK reference guide for the latest status/ + * Refer to the SDK reference guide for the latest status. * * @param eventName the name of the event * @param user the user that performed the event From 87da94916651a119945d6cd7d46f195fda2d31a4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 6 Sep 2019 14:15:52 -0700 Subject: [PATCH 175/327] add tests and doc comments about float->int rounding --- .../com/launchdarkly/client/LDClientInterface.java | 7 ++++++- .../launchdarkly/client/LDClientEvaluationTest.java | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 6385f474f..2fcd7db66 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -80,7 +80,9 @@ public interface LDClientInterface extends Closeable { /** * Calculates the integer value of a feature flag for a given user. - * + *

    + * If the flag variation has a numeric value that is not an integer, it is rounded toward zero (truncated). + * * @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,6 +136,9 @@ 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. The {@code reason} property in the result will also be included in * analytics events, if you are capturing detailed event data for this flag. + *

    + * If the flag variation has a numeric value that is not an integer, it is rounded toward zero (truncated). + * * @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 diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 8a5ef6586..a509d076f 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -74,6 +74,19 @@ public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception assertEquals(new Integer(2), client.intVariation("key", user, 1)); } + + @Test + public void intVariationFromDoubleRoundsTowardZero() throws Exception { + featureStore.upsert(FEATURES, flagWithValue("flag1", jdouble(2.25))); + featureStore.upsert(FEATURES, flagWithValue("flag2", jdouble(2.75))); + featureStore.upsert(FEATURES, flagWithValue("flag3", jdouble(-2.25))); + featureStore.upsert(FEATURES, flagWithValue("flag4", jdouble(-2.75))); + + assertEquals(new Integer(2), client.intVariation("flag1", user, 1)); + assertEquals(new Integer(2), client.intVariation("flag2", user, 1)); + assertEquals(new Integer(-2), client.intVariation("flag3", user, 1)); + assertEquals(new Integer(-2), client.intVariation("flag4", user, 1)); + } @Test public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { From db30229e2a59765e9c568d5ba930724c4ae0b39a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 27 Sep 2019 13:32:25 -0700 Subject: [PATCH 176/327] Revert "Revert "Merge pull request #116 from launchdarkly/eb/ch32302/experimentation-events"" This reverts commit 1c49f9b30e82a7019605f25148c5610ac9da931a. --- .../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, 249 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 fbf3bc171..ad201db5a 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,34 @@ 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 c9ed7f31c..af2a37016 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -29,6 +29,7 @@ class FeatureFlag implements VersionedData { private List variations; private boolean clientSide; private boolean trackEvents; + private boolean trackEventsFallthrough; private Long debugEventsUntilDate; private boolean deleted; @@ -37,7 +38,8 @@ class FeatureFlag implements VersionedData { 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; @@ -50,6 +52,7 @@ class FeatureFlag implements VersionedData { this.variations = variations; this.clientSide = clientSide; this.trackEvents = trackEvents; + this.trackEventsFallthrough = trackEventsFallthrough; this.debugEventsUntilDate = debugEventsUntilDate; this.deleted = deleted; } @@ -167,6 +170,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 c3b6ff1fe..7004a88cf 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"); @@ -299,6 +303,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") @@ -399,6 +508,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, @@ -412,5 +523,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 d791fc939..a8cac0ed0 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 ab96d0f92c98a301615caa3dfcb09ccc5183c2b9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 27 Sep 2019 18:28:38 -0700 Subject: [PATCH 177/327] add LDValue type and deprecate use of JsonElement --- .../java/com/launchdarkly/client/Clause.java | 47 +- .../launchdarkly/client/EvaluationDetail.java | 124 +++++- .../java/com/launchdarkly/client/Event.java | 44 +- .../com/launchdarkly/client/EventFactory.java | 30 +- .../com/launchdarkly/client/EventOutput.java | 24 +- .../launchdarkly/client/EventSummarizer.java | 10 +- .../com/launchdarkly/client/FeatureFlag.java | 43 +- .../client/FeatureFlagBuilder.java | 8 +- .../client/FeatureFlagsState.java | 6 +- .../com/launchdarkly/client/LDClient.java | 101 +++-- .../client/LDClientInterface.java | 53 ++- .../java/com/launchdarkly/client/LDUser.java | 194 +++++---- .../com/launchdarkly/client/OperandType.java | 17 +- .../com/launchdarkly/client/Operator.java | 49 ++- .../launchdarkly/client/TestFeatureStore.java | 8 +- .../launchdarkly/client/UserAttribute.java | 24 +- .../java/com/launchdarkly/client/Util.java | 12 +- .../client/VariationOrRollout.java | 29 +- .../launchdarkly/client/VariationType.java | 53 --- .../client/value/ArrayBuilder.java | 32 ++ .../launchdarkly/client/value/LDValue.java | 407 ++++++++++++++++++ .../client/value/LDValueArray.java | 64 +++ .../client/value/LDValueBool.java | 50 +++ .../client/value/LDValueJsonElement.java | 195 +++++++++ .../client/value/LDValueNull.java | 35 ++ .../client/value/LDValueNumber.java | 70 +++ .../client/value/LDValueObject.java | 69 +++ .../client/value/LDValueString.java | 41 ++ .../client/value/LDValueType.java | 34 ++ .../client/value/LDValueTypeAdapter.java | 53 +++ .../client/value/ObjectBuilder.java | 44 ++ .../client/DefaultEventProcessorTest.java | 47 +- .../client/EventSummarizerTest.java | 20 +- .../launchdarkly/client/FeatureFlagTest.java | 126 +++--- .../client/FeatureFlagsStateTest.java | 22 +- .../client/LDClientEndToEndTest.java | 5 +- .../client/LDClientEvaluationTest.java | 126 +++--- .../client/LDClientEventTest.java | 152 ++++--- .../client/LDClientLddModeTest.java | 5 +- .../client/LDClientOfflineTest.java | 6 +- .../com/launchdarkly/client/LDClientTest.java | 11 +- .../com/launchdarkly/client/LDUserTest.java | 90 ++-- .../client/OperatorParameterizedTest.java | 151 +++---- .../com/launchdarkly/client/OperatorTest.java | 11 +- .../com/launchdarkly/client/SegmentTest.java | 23 +- .../com/launchdarkly/client/TestUtil.java | 17 +- .../com/launchdarkly/client/UtilTest.java | 22 +- .../client/value/LDValueTest.java | 362 ++++++++++++++++ 48 files changed, 2426 insertions(+), 740 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/VariationType.java create mode 100644 src/main/java/com/launchdarkly/client/value/ArrayBuilder.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValue.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueArray.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueBool.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueNull.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueNumber.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueObject.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueString.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueType.java create mode 100644 src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java create mode 100644 src/main/java/com/launchdarkly/client/value/ObjectBuilder.java create mode 100644 src/test/java/com/launchdarkly/client/value/LDValueTest.java diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java index 2f69bf79b..1cda00c33 100644 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -1,27 +1,27 @@ package com.launchdarkly.client; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.client.value.LDValueType; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; - import java.util.List; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; + class Clause { private final static Logger logger = LoggerFactory.getLogger(Clause.class); private String attribute; private Operator op; - private List values; //interpreted as an OR of values + private List values; //interpreted as an OR of values private boolean negate; public Clause() { } - public Clause(String attribute, Operator op, List values, boolean negate) { + public Clause(String attribute, Operator op, List values, boolean negate) { this.attribute = attribute; this.op = op; this.values = values; @@ -29,28 +29,27 @@ public Clause(String attribute, Operator op, List values, boolean } boolean matchesUserNoSegments(LDUser user) { - JsonElement userValue = user.getValueForEvaluation(attribute); - if (userValue == null) { + LDValue userValue = user.getValueForEvaluation(attribute); + if (userValue.isNull()) { return false; } - if (userValue.isJsonArray()) { - JsonArray array = userValue.getAsJsonArray(); - for (JsonElement jsonElement : array) { - if (!jsonElement.isJsonPrimitive()) { - logger.error("Invalid custom attribute value in user object for user key \"{}\": {}", user.getKey(), jsonElement); + if (userValue.getType() == LDValueType.ARRAY) { + for (LDValue value: userValue.values()) { + if (value.getType() == LDValueType.ARRAY || value.getType() == LDValueType.OBJECT) { + logger.error("Invalid custom attribute value in user object for user key \"{}\": {}", user.getKey(), value); return false; } - if (matchAny(jsonElement.getAsJsonPrimitive())) { + if (matchAny(value)) { return maybeNegate(true); } } return maybeNegate(false); - } else if (userValue.isJsonPrimitive()) { - return maybeNegate(matchAny(userValue.getAsJsonPrimitive())); + } else if (userValue.getType() != LDValueType.OBJECT) { + return maybeNegate(matchAny(userValue)); } logger.warn("Got unexpected user attribute type \"{}\" for user key \"{}\" and attribute \"{}\"", - userValue.getClass().getName(), user.getKey(), attribute); + userValue.getType(), user.getKey(), attribute); return false; } @@ -58,9 +57,9 @@ boolean matchesUser(FeatureStore store, LDUser user) { // In the case of a segment match operator, we check if the user is in any of the segments, // and possibly negate if (op == Operator.segmentMatch) { - for (JsonPrimitive j: values) { - if (j.isString()) { - Segment segment = store.get(SEGMENTS, j.getAsString()); + for (LDValue j: values) { + if (j.getType() == LDValueType.STRING) { + Segment segment = store.get(SEGMENTS, j.stringValue()); if (segment != null) { if (segment.matchesUser(user)) { return maybeNegate(true); @@ -74,9 +73,9 @@ boolean matchesUser(FeatureStore store, LDUser user) { return matchesUserNoSegments(user); } - private boolean matchAny(JsonPrimitive userValue) { + private boolean matchAny(LDValue userValue) { if (op != null) { - for (JsonPrimitive v : values) { + for (LDValue v : values) { if (op.apply(userValue, v)) { return true; } @@ -91,6 +90,4 @@ private boolean maybeNegate(boolean b) { else return b; } - - } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 0bb2880b9..2f3cd9ead 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -1,10 +1,13 @@ package com.launchdarkly.client; import com.google.common.base.Objects; +import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; /** * 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. + * @param the type of the wrapped value * @since 4.3.0 */ public class EvaluationDetail { @@ -12,15 +15,113 @@ public class EvaluationDetail { private final EvaluationReason reason; private final Integer variationIndex; private final T value; - + private final LDValue jsonValue; + + /** + * Constructs an instance without the {@code jsonValue} property. + * + * @param reason an {@link EvaluationReason} (should not be null) + * @param variationIndex an optional variation index + * @param value a value of the desired type + */ + @Deprecated public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { - this.reason = reason; + this.value = value; + this.jsonValue = toLDValue(value); this.variationIndex = variationIndex; + this.reason = reason; + } + + /** + * Constructs an instance with all properties specified. + * + * @param reason an {@link EvaluationReason} (should not be null) + * @param variationIndex an optional variation index + * @param value a value of the desired type + * @param jsonValue the {@link LDValue} representation of the value + * @since 4.8.0 + */ + private EvaluationDetail(T value, LDValue jsonValue, Integer variationIndex, EvaluationReason reason) { this.value = value; + this.jsonValue = jsonValue == null ? LDValue.ofNull() : jsonValue; + this.variationIndex = variationIndex; + this.reason = reason; } - static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, T defaultValue) { - return new EvaluationDetail<>(EvaluationReason.error(errorKind), null, defaultValue); + /** + * Factory method for an arbitrary value. + * + * @param value a value of the desired type + * @param variationIndex an optional variation index + * @param reason an {@link EvaluationReason} (should not be null) + * @return an {@link EvaluationDetail} + * @since 4.8.0 + */ + public static EvaluationDetail fromValue(T value, Integer variationIndex, EvaluationReason reason) { + return new EvaluationDetail<>(value, toLDValue(value), variationIndex, reason); + } + + /** + * Factory method for using an {@link LDValue} as the value. + * + * @param jsonValue a value + * @param variationIndex an optional variation index + * @param reason an {@link EvaluationReason} (should not be null) + * @return an {@link EvaluationDetail} + * @since 4.8.0 + */ + public static EvaluationDetail fromJsonValue(LDValue jsonValue, Integer variationIndex, EvaluationReason reason) { + return new EvaluationDetail<>(jsonValue, jsonValue, variationIndex, reason); + } + + /** + * Factory method for an arbitrary value that also specifies it as a {@link LDValue}. + * + * @param value a value of the desired type + * @param jsonValue the same value represented as an {@link LDValue} + * @param variationIndex an optional variation index + * @param reason an {@link EvaluationReason} (should not be null) + * @return an {@link EvaluationDetail} + * @since 4.8.0 + */ + public static EvaluationDetail fromValueWithJsonValue(T value, LDValue jsonValue, Integer variationIndex, EvaluationReason reason) { + return new EvaluationDetail<>(value, jsonValue, variationIndex, reason); + } + + static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { + return new EvaluationDetail<>(defaultValue == null ? LDValue.ofNull() : defaultValue, defaultValue, null, EvaluationReason.error(errorKind)); + } + + @SuppressWarnings("deprecation") + private static LDValue toLDValue(Object value) { + if (value == null) { + return LDValue.ofNull(); + } + if (value instanceof LDValue) { + return (LDValue)value; + } + if (value instanceof JsonElement) { + return LDValue.fromJsonElement((JsonElement)value); + } + if (value instanceof Boolean) { + return LDValue.of(((Boolean)value).booleanValue()); + } + if (value instanceof Integer) { + return LDValue.of(((Integer)value).intValue()); + } + if (value instanceof Long) { + return LDValue.of(((Long)value).longValue()); + } + if (value instanceof Float) { + return LDValue.of(((Float)value).floatValue()); + } + if (value instanceof Double) { + return LDValue.of(((Double)value).doubleValue()); + } + if (value instanceof String) { + return LDValue.of((String)value); + } + return LDValue.ofNull(); } /** @@ -49,6 +150,16 @@ public T getValue() { return value; } + /** + * The result of the flag evaluation as an {@link LDValue}. 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 + * @since 4.8.0 + */ + public LDValue getJsonValue() { + return jsonValue; + } + /** * Returns true if the flag evaluation returned the default value, rather than one of the flag's * variations. @@ -63,14 +174,15 @@ public boolean equals(Object other) { if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") EvaluationDetail o = (EvaluationDetail)other; - return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value); + return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value) + && Objects.equal(jsonValue, o.jsonValue); } return false; } @Override public int hashCode() { - return Objects.hashCode(reason, variationIndex, value); + return Objects.hashCode(reason, variationIndex, value, jsonValue); } @Override diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/Event.java index 8d10f6222..6f9b2121a 100644 --- a/src/main/java/com/launchdarkly/client/Event.java +++ b/src/main/java/com/launchdarkly/client/Event.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; /** * Base class for all analytics events that are generated by the client. Also defines all of its own subclasses. @@ -16,18 +17,22 @@ public Event(long creationDate, LDUser user) { public static final class Custom extends Event { final String key; - final JsonElement data; + final LDValue data; final Double metricValue; - public Custom(long timestamp, String key, LDUser user, JsonElement data, Double metricValue) { + /** + * @since 4.8.0 + */ + public Custom(long timestamp, String key, LDUser user, LDValue data, Double metricValue) { super(timestamp, user); this.key = key; - this.data = data; + this.data = data == null ? LDValue.ofNull() : data; this.metricValue = metricValue; } + @Deprecated public Custom(long timestamp, String key, LDUser user, JsonElement data) { - this(timestamp, key, user, data, null); + this(timestamp, key, user, LDValue.unsafeFromJsonElement(data), null); } } @@ -46,8 +51,8 @@ public Index(long timestamp, LDUser user) { public static final class FeatureRequest extends Event { final String key; final Integer variation; - final JsonElement value; - final JsonElement defaultVal; + final LDValue value; + final LDValue defaultVal; final Integer version; final String prereqOf; final boolean trackEvents; @@ -55,14 +60,11 @@ public static final class FeatureRequest extends Event { 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) { + /** + * @since 4.8.0 + */ + public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, LDValue value, + LDValue defaultVal, EvaluationReason reason, String prereqOf, boolean trackEvents, Long debugEventsUntilDate, boolean debug) { super(timestamp, user); this.key = key; this.version = version; @@ -75,6 +77,20 @@ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, this.reason = reason; this.debug = 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, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), + null, prereqOf, trackEvents, debugEventsUntilDate, debug); + } + + @Deprecated + 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) { + this(timestamp, key, user, version, variation, LDValue.unsafeFromJsonElement(value), LDValue.unsafeFromJsonElement(defaultVal), + reason, prereqOf, trackEvents, debugEventsUntilDate, debug); + } } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index ad201db5a..4afc7240f 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; abstract class EventFactory { public static final EventFactory DEFAULT = new DefaultEventFactory(false); @@ -9,8 +9,8 @@ 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) { + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue value, + Integer variationIndex, EvaluationReason reason, LDValue defaultValue, String prereqOf) { boolean requireExperimentData = isExperiment(flag, reason); return new Event.FeatureRequest( getTimestamp(), @@ -20,46 +20,46 @@ public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user variationIndex, value, defaultValue, + (requireExperimentData || isIncludeReasons()) ? reason : null, prereqOf, requireExperimentData || flag.isTrackEvents(), flag.getDebugEventsUntilDate(), - (requireExperimentData || isIncludeReasons()) ? reason : null, false ); } - public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, JsonElement defaultVal) { + public Event.FeatureRequest newFeatureRequestEvent(FeatureFlag flag, LDUser user, EvaluationDetail result, LDValue defaultVal) { 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, + public Event.FeatureRequest newDefaultFeatureRequestEvent(FeatureFlag flag, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { return new Event.FeatureRequest(getTimestamp(), flag.getKey(), user, flag.getVersion(), - null, defaultValue, defaultValue, null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), - isIncludeReasons() ? EvaluationReason.error(errorKind) : null, false); + null, defaultValue, defaultValue, isIncludeReasons() ? EvaluationReason.error(errorKind) : null, + null, flag.isTrackEvents(), flag.getDebugEventsUntilDate(), false); } - public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, JsonElement defaultValue, + public Event.FeatureRequest newUnknownFeatureRequestEvent(String key, LDUser user, LDValue defaultValue, EvaluationReason.ErrorKind errorKind) { - return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, null, false, null, - isIncludeReasons() ? EvaluationReason.error(errorKind) : null, false); + return new Event.FeatureRequest(getTimestamp(), key, user, null, null, defaultValue, defaultValue, + isIncludeReasons() ? EvaluationReason.error(errorKind) : null, null, false, null, false); } - public Event.FeatureRequest newPrerequisiteFeatureRequestEvent(FeatureFlag prereqFlag, LDUser user, EvaluationDetail result, + 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()); + LDValue.ofNull(), prereqOf.getKey()); } 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, from.reason, true); + from.defaultVal, from.reason, from.prereqOf, from.trackEvents, from.debugEventsUntilDate, true); } - public Event.Custom newCustomEvent(String key, LDUser user, JsonElement data, Double metricValue) { + public Event.Custom newCustomEvent(String key, LDUser user, LDValue data, Double metricValue) { return new Event.Custom(getTimestamp(), key, user, data, metricValue); } diff --git a/src/main/java/com/launchdarkly/client/EventOutput.java b/src/main/java/com/launchdarkly/client/EventOutput.java index 8ccd12403..866ab298a 100644 --- a/src/main/java/com/launchdarkly/client/EventOutput.java +++ b/src/main/java/com/launchdarkly/client/EventOutput.java @@ -1,9 +1,9 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; import com.google.gson.annotations.SerializedName; import com.launchdarkly.client.EventSummarizer.CounterKey; import com.launchdarkly.client.EventSummarizer.CounterValue; +import com.launchdarkly.client.value.LDValue; import java.util.ArrayList; import java.util.HashMap; @@ -41,13 +41,13 @@ static final class FeatureRequest extends EventOutputWithTimestamp { private final LDUser user; private final Integer version; private final Integer variation; - private final JsonElement value; - @SerializedName("default") private final JsonElement defaultVal; + private final LDValue value; + @SerializedName("default") private final LDValue 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, + Integer version, Integer variation, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason, boolean debug) { super(debug ? "debug" : "feature", creationDate); this.key = key; @@ -56,7 +56,7 @@ static final class FeatureRequest extends EventOutputWithTimestamp { this.variation = variation; this.version = version; this.value = value; - this.defaultVal = defaultVal; + this.defaultVal = defaultVal.isNull() ? null : defaultVal; // allows Gson to omit this property this.prereqOf = prereqOf; this.reason = reason; } @@ -79,15 +79,15 @@ static final class Custom extends EventOutputWithTimestamp { private final String key; private final String userKey; private final LDUser user; - private final JsonElement data; + private final LDValue data; private final Double metricValue; - Custom(long creationDate, String key, String userKey, LDUser user, JsonElement data, Double metricValue) { + Custom(long creationDate, String key, String userKey, LDUser user, LDValue data, Double metricValue) { super("custom", creationDate); this.key = key; this.userKey = userKey; this.user = user; - this.data = data; + this.data = (data == null || data.isNull()) ? null : data; // allows Gson to omit this property this.metricValue = metricValue; } } @@ -117,10 +117,10 @@ static final class Summary extends EventOutput { } static final class SummaryEventFlag { - @SerializedName("default") final JsonElement defaultVal; + @SerializedName("default") final LDValue defaultVal; final List counters; - SummaryEventFlag(JsonElement defaultVal, List counters) { + SummaryEventFlag(LDValue defaultVal, List counters) { this.defaultVal = defaultVal; this.counters = counters; } @@ -128,12 +128,12 @@ static final class SummaryEventFlag { static final class SummaryEventCounter { final Integer variation; - final JsonElement value; + final LDValue value; final Integer version; final long count; final Boolean unknown; - SummaryEventCounter(Integer variation, JsonElement value, Integer version, long count, Boolean unknown) { + SummaryEventCounter(Integer variation, LDValue 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 3c454914b..b21c0d870 100644 --- a/src/main/java/com/launchdarkly/client/EventSummarizer.java +++ b/src/main/java/com/launchdarkly/client/EventSummarizer.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import java.util.HashMap; import java.util.Map; @@ -64,7 +64,7 @@ boolean isEmpty() { return counters.isEmpty(); } - void incrementCounter(String flagKey, Integer variation, Integer version, JsonElement flagValue, JsonElement defaultVal) { + void incrementCounter(String flagKey, Integer variation, Integer version, LDValue flagValue, LDValue defaultVal) { CounterKey key = new CounterKey(flagKey, variation, version); CounterValue value = counters.get(key); @@ -133,10 +133,10 @@ public String toString() { static final class CounterValue { long count; - final JsonElement flagValue; - final JsonElement defaultVal; + final LDValue flagValue; + final LDValue defaultVal; - CounterValue(long count, JsonElement flagValue, JsonElement defaultVal) { + CounterValue(long count, LDValue flagValue, LDValue defaultVal) { this.count = count; this.flagValue = flagValue; this.defaultVal = defaultVal; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index af2a37016..aff0b75ce 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -1,15 +1,12 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; -import com.google.gson.reflect.TypeToken; +import com.launchdarkly.client.value.LDValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; -import java.util.Map; import static com.google.common.base.Preconditions.checkNotNull; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -26,7 +23,7 @@ class FeatureFlag implements VersionedData { private List rules; private VariationOrRollout fallthrough; private Integer offVariation; //optional - private List variations; + private List variations; private boolean clientSide; private boolean trackEvents; private boolean trackEventsFallthrough; @@ -37,7 +34,7 @@ class FeatureFlag implements VersionedData { FeatureFlag() {} FeatureFlag(String key, int version, boolean on, List prerequisites, String salt, List targets, - List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, + List rules, VariationOrRollout fallthrough, Integer offVariation, List variations, boolean clientSide, boolean trackEvents, boolean trackEventsFallthrough, Long debugEventsUntilDate, boolean deleted) { this.key = key; @@ -63,14 +60,14 @@ EvalResult evaluate(LDUser user, FeatureStore featureStore, EventFactory eventFa if (user == null || user.getKey() == null) { // this should have been prevented by LDClient.evaluateInternal logger.warn("Null user or null user key when evaluating flag \"{}\"; returning null", key); - return new EvalResult(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, null), prereqEvents); + return new EvalResult(EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, LDValue.ofNull()), prereqEvents); } - EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); + EvaluationDetail details = evaluate(user, featureStore, prereqEvents, eventFactory); return new EvalResult(details, prereqEvents); } - private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, + private EvaluationDetail evaluate(LDUser user, FeatureStore featureStore, List events, EventFactory eventFactory) { if (!isOn()) { return getOffValue(EvaluationReason.off()); @@ -85,7 +82,7 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore feature if (targets != null) { for (Target target: targets) { for (String v : target.getValues()) { - if (v.equals(user.getKey().getAsString())) { + if (v.equals(user.getKey().stringValue())) { return getVariation(target.getVariation(), EvaluationReason.targetMatch()); } } @@ -119,7 +116,7 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto logger.error("Could not retrieve prerequisite flag \"{}\" when evaluating \"{}\"", prereq.getKey(), key); prereqOk = false; } else { - EvaluationDetail prereqEvalResult = prereqFeatureFlag.evaluate(user, featureStore, events, eventFactory); + 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()) { @@ -134,26 +131,28 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto return null; } - private EvaluationDetail getVariation(int variation, EvaluationReason reason) { + 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 EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, LDValue.ofNull()); } - return new EvaluationDetail(reason, variation, variations.get(variation)); + LDValue value = LDValue.normalize(variations.get(variation)); + // normalize() ensures that nulls become LDValue.ofNull() - Gson may give us nulls + return EvaluationDetail.fromJsonValue(value, variation, reason); } - private EvaluationDetail getOffValue(EvaluationReason reason) { + private EvaluationDetail getOffValue(EvaluationReason reason) { if (offVariation == null) { // off variation unspecified - return default value - return new EvaluationDetail(reason, null, null); + return EvaluationDetail.fromJsonValue(LDValue.ofNull(), null, reason); } return getVariation(offVariation, reason); } - private EvaluationDetail getValueForVariationOrRollout(VariationOrRollout vr, LDUser user, EvaluationReason reason) { + private EvaluationDetail getValueForVariationOrRollout(VariationOrRollout vr, LDUser user, EvaluationReason reason) { Integer index = vr.variationIndexForUser(user, key, salt); if (index == null) { 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 EvaluationDetail.error(EvaluationReason.ErrorKind.MALFORMED_FLAG, LDValue.ofNull()); } return getVariation(index, reason); } @@ -206,7 +205,7 @@ VariationOrRollout getFallthrough() { return fallthrough; } - List getVariations() { + List getVariations() { return variations; } @@ -219,17 +218,17 @@ boolean isClientSide() { } static class EvalResult { - private final EvaluationDetail details; + private final EvaluationDetail details; private final List prerequisiteEvents; - private EvalResult(EvaluationDetail details, List prerequisiteEvents) { + private EvalResult(EvaluationDetail details, List prerequisiteEvents) { checkNotNull(details); checkNotNull(prerequisiteEvents); this.details = details; this.prerequisiteEvents = prerequisiteEvents; } - EvaluationDetail getDetails() { + EvaluationDetail getDetails() { return details; } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java index e4111ab24..e97245985 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -1,6 +1,6 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import java.util.ArrayList; import java.util.Arrays; @@ -16,7 +16,7 @@ class FeatureFlagBuilder { private List rules = new ArrayList<>(); private VariationOrRollout fallthrough; private Integer offVariation; - private List variations = new ArrayList<>(); + private List variations = new ArrayList<>(); private boolean clientSide; private boolean trackEvents; private boolean trackEventsFallthrough; @@ -87,12 +87,12 @@ FeatureFlagBuilder offVariation(Integer offVariation) { return this; } - FeatureFlagBuilder variations(List variations) { + FeatureFlagBuilder variations(List variations) { this.variations = variations; return this; } - FeatureFlagBuilder variations(JsonElement... variations) { + FeatureFlagBuilder variations(LDValue... variations) { return variations(Arrays.asList(variations)); } diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java index 559c219ff..0b9577496 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagsState.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagsState.java @@ -7,6 +7,7 @@ import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.client.value.LDValue; import java.io.IOException; import java.util.Collections; @@ -144,8 +145,9 @@ Builder valid(boolean valid) { return this; } - Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { - flagValues.put(flag.getKey(), eval.getValue()); + @SuppressWarnings("deprecation") + Builder addFlag(FeatureFlag flag, EvaluationDetail eval) { + flagValues.put(flag.getKey(), eval.getValue().asUnsafeJsonElement()); final boolean flagIsTracked = flag.isTrackEvents() || (flag.getDebugEventsUntilDate() != null && flag.getDebugEventsUntilDate() > System.currentTimeMillis()); final boolean wantDetails = !detailsOnlyForTrackedFlags || flagIsTracked; diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index defaacc73..893080d5a 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; import org.apache.commons.codec.binary.Hex; import org.slf4j.Logger; @@ -111,21 +111,27 @@ public boolean initialized() { @Override public void track(String eventName, LDUser user) { - track(eventName, user, null); + trackData(eventName, user, LDValue.ofNull()); } @Override - public void track(String eventName, LDUser user, JsonElement data) { - if (user == null || user.getKey() == null) { + public void trackData(String eventName, LDUser user, LDValue data) { + if (user == null || user.getKeyAsString() == null) { logger.warn("Track called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data, null)); } } + @SuppressWarnings("deprecation") + @Override + public void track(String eventName, LDUser user, JsonElement data) { + trackData(eventName, user, LDValue.unsafeFromJsonElement(data)); + } + @Override - public void track(String eventName, LDUser user, JsonElement data, double metricValue) { - if (user == null || user.getKey() == null) { + public void track(String eventName, LDUser user, LDValue data, double metricValue) { + if (user == null || user.getKeyAsString() == null) { logger.warn("Track called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newCustomEvent(eventName, user, data, metricValue)); @@ -134,7 +140,7 @@ public void track(String eventName, LDUser user, JsonElement data, double metric @Override public void identify(LDUser user) { - if (user == null || user.getKey() == null) { + if (user == null || user.getKeyAsString() == null) { logger.warn("Identify called with null user or null user key!"); } else { eventProcessor.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(user)); @@ -172,7 +178,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } } - if (user == null || user.getKey() == null) { + if (user == null || user.getKeyAsString() == null) { logger.warn("allFlagsState() was called with null user or null user key! returning no data"); return builder.valid(false).build(); } @@ -185,12 +191,12 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) continue; } try { - EvaluationDetail result = flag.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + EvaluationDetail result = flag.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); builder.addFlag(flag, result); } catch (Exception 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(), EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, null)); + builder.addFlag(entry.getValue(), EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, LDValue.ofNull())); } } return builder.build(); @@ -198,57 +204,79 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) @Override public boolean boolVariation(String featureKey, LDUser user, boolean defaultValue) { - return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean); + return evaluate(featureKey, user, LDValue.of(defaultValue), true).booleanValue(); } @Override public Integer intVariation(String featureKey, LDUser user, int defaultValue) { - return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer); + return evaluate(featureKey, user, LDValue.of(defaultValue), true).intValue(); } @Override public Double doubleVariation(String featureKey, LDUser user, Double defaultValue) { - return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double); + return evaluate(featureKey, user, LDValue.of(defaultValue), true).doubleValue(); } @Override public String stringVariation(String featureKey, LDUser user, String defaultValue) { - return evaluate(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String); + return evaluate(featureKey, user, LDValue.of(defaultValue), true).stringValue(); } + @SuppressWarnings("deprecation") @Override public JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue) { - return evaluate(featureKey, user, defaultValue, defaultValue, VariationType.Json); + return evaluate(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false).asUnsafeJsonElement(); + } + + @Override + public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaultValue) { + return evaluate(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false); } @Override public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Boolean, + EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().booleanValue(), details.getJsonValue(), + details.getVariationIndex(), details.getReason()); } @Override public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Integer, + EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().intValue(), details.getJsonValue(), + details.getVariationIndex(), details.getReason()); } @Override public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.Double, + EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().doubleValue(), details.getJsonValue(), + details.getVariationIndex(), details.getReason()); } @Override public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, new JsonPrimitive(defaultValue), VariationType.String, + EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().stringValue(), details.getJsonValue(), + details.getVariationIndex(), details.getReason()); } + @SuppressWarnings("deprecation") @Override public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { - return evaluateDetail(featureKey, user, defaultValue, defaultValue, VariationType.Json, + EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false, EventFactory.DEFAULT_WITH_REASONS); + return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().asUnsafeJsonElement(), details.getJsonValue(), + details.getVariationIndex(), details.getReason()); + } + + @Override + public EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue defaultValue) { + return evaluateDetail(featureKey, user, defaultValue == null ? LDValue.ofNull() : defaultValue, false, EventFactory.DEFAULT_WITH_REASONS); } @Override @@ -274,28 +302,23 @@ public boolean isFlagKnown(String featureKey) { return false; } - private T evaluate(String featureKey, LDUser user, T defaultValue, JsonElement defaultJson, VariationType expectedType) { - return evaluateDetail(featureKey, user, defaultValue, defaultJson, expectedType, EventFactory.DEFAULT).getValue(); + private LDValue evaluate(String featureKey, LDUser user, LDValue defaultValue, boolean checkType) { + return evaluateDetail(featureKey, user, defaultValue, checkType, EventFactory.DEFAULT).getValue(); } - private EvaluationDetail evaluateDetail(String featureKey, LDUser user, T defaultValue, - JsonElement defaultJson, VariationType expectedType, EventFactory eventFactory) { - EvaluationDetail details = evaluateInternal(featureKey, user, defaultJson, eventFactory); - T resultValue = null; - if (details.getReason().getKind() == EvaluationReason.Kind.ERROR) { - resultValue = defaultValue; - } else if (details.getValue() != null) { - try { - resultValue = expectedType.coerceValue(details.getValue()); - } catch (EvaluationException e) { - logger.error("Encountered exception in LaunchDarkly client: " + e); + private EvaluationDetail evaluateDetail(String featureKey, LDUser user, LDValue defaultValue, + boolean checkType, EventFactory eventFactory) { + EvaluationDetail details = evaluateInternal(featureKey, user, defaultValue, eventFactory); + if (details.getValue() != null && checkType) { + if (defaultValue.getType() != details.getValue().getType()) { + logger.error("Feature flag evaluation expected result as {}, but got {}", defaultValue.getType(), details.getValue().getType()); return EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, defaultValue); } } - return new EvaluationDetail(details.getReason(), details.getVariationIndex(), resultValue); + return details; } - private EvaluationDetail evaluateInternal(String featureKey, LDUser user, JsonElement defaultValue, EventFactory eventFactory) { + private EvaluationDetail evaluateInternal(String featureKey, LDUser user, LDValue defaultValue, EventFactory eventFactory) { if (!initialized()) { if (featureStore.initialized()) { logger.warn("Evaluation called before client initialized for feature flag \"{}\"; using last known values from feature store", featureKey); @@ -316,7 +339,7 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); return EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, defaultValue); } - if (user == null || user.getKey() == null) { + if (user == null || user.getKeyAsString() == null) { logger.warn("Null user or null user key when evaluating flag \"{}\"; returning default value", featureKey); sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); @@ -329,9 +352,9 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser for (Event.FeatureRequest event : evalResult.getPrerequisiteEvents()) { eventProcessor.sendEvent(event); } - EvaluationDetail details = evalResult.getDetails(); + EvaluationDetail details = evalResult.getDetails(); if (details.isDefaultValue()) { - details = new EvaluationDetail(details.getReason(), null, defaultValue); + details = EvaluationDetail.fromJsonValue(defaultValue, null, details.getReason()); } sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, details, defaultValue)); return details; @@ -383,7 +406,7 @@ public boolean isOffline() { @Override public String secureModeHash(LDUser user) { - if (user == null || user.getKey() == null) { + if (user == null || user.getKeyAsString() == null) { return null; } try { diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 57292e730..0516f4a47 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import java.io.Closeable; import java.io.IOException; @@ -14,6 +15,8 @@ public interface LDClientInterface extends Closeable { /** * Tracks that a user performed an event. + *

    + * To add custom data to the event, use {@link #trackData(String, LDUser, LDValue)}. * * @param eventName the name of the event * @param user the user that performed the event @@ -26,9 +29,21 @@ public interface LDClientInterface extends Closeable { * @param eventName the name of the event * @param user the user that performed the event * @param data a JSON object containing additional data associated with the event; may be null + * @deprecated Use {@link #trackData(String, LDUser, LDValue)}. */ + @Deprecated void track(String eventName, LDUser user, JsonElement data); + /** + * Tracks that a user performed an event, and provides additional custom data. + * + * @param eventName the name of the event + * @param user the user that performed the event + * @param data an {@link LDValue} containing additional data associated with the event + * @since 4.8.0 + */ + void trackData(String eventName, LDUser user, LDValue data); + /** * Tracks that a user performed an event, and provides an additional numeric value for custom metrics. *

    @@ -39,13 +54,14 @@ public interface LDClientInterface extends Closeable { * * @param eventName the name of the event * @param user the user that performed the event - * @param data a JSON object containing additional data associated with the event; may be null + * @param data an {@link LDValue} containing additional data associated with the event; if not applicable, + * you may pass either {@code null} or {@link LDValue#ofNull()} * @param metricValue a numeric value used by the LaunchDarkly experimentation feature in numeric custom * metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be * returned as part of the custom event for Data Export. * @since 4.8.0 */ - void track(String eventName, LDUser user, JsonElement data, double metricValue); + void track(String eventName, LDUser user, LDValue data, double metricValue); /** * Registers the user. @@ -135,9 +151,25 @@ public interface LDClientInterface extends Closeable { * @param user the end user requesting the flag * @param defaultValue the default value of the flag * @return the variation for the given user, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel + * @deprecated Use {@link #jsonValueVariation(String, LDUser, LDValue)}. Gson types may be removed + * from the public API in the future. */ + @Deprecated JsonElement jsonVariation(String featureKey, LDUser user, JsonElement defaultValue); + /** + * Calculates the {@link LDValue} value of a feature flag for a given user. + * + * @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 the variation for the given user, or {@code defaultValue} if the flag is disabled in the LaunchDarkly control panel; + * will never be a null reference, but may be {@link LDValue#ofNull()} + * + * @since 4.8.0 + */ + LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaultValue); + /** * Calculates the value of a feature flag for a given user, and returns an object that describes the * way the value was determined. The {@code reason} property in the result will also be included in @@ -198,9 +230,24 @@ public interface LDClientInterface extends Closeable { * @param defaultValue the default value of the flag * @return an {@link EvaluationDetail} object * @since 2.3.0 + * @deprecated Use {@link #jsonValueVariationDetail(String, LDUser, LDValue)}. Gson types may be removed + * from the public API in the future. */ + @Deprecated EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue); - + + /** + * Calculates the {@link LDValue} value of a feature flag for a given user. + * + * @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 EvaluationDetail} object + * + * @since 4.8.0 + */ + EvaluationDetail jsonValueVariationDetail(String featureKey, LDUser user, LDValue 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/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index fee9fd7f2..450fddd90 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -9,6 +9,7 @@ import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.client.value.LDValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,35 +41,35 @@ 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; - private JsonPrimitive email; - private JsonPrimitive name; - private JsonPrimitive avatar; - private JsonPrimitive firstName; - private JsonPrimitive lastName; - private JsonPrimitive anonymous; - private JsonPrimitive country; - private Map custom; + // Note that these fields are all stored internally as LDValue rather than String so that + // we don't waste time repeatedly converting them to LDValue in the rule evaluation logic. + private final LDValue key; + private LDValue secondary; + private LDValue ip; + private LDValue email; + private LDValue name; + private LDValue avatar; + private LDValue firstName; + private LDValue lastName; + private LDValue anonymous; + private LDValue country; + private Map custom; Set privateAttributeNames; protected LDUser(Builder builder) { if (builder.key == null || builder.key.equals("")) { logger.warn("User was created with null/empty key"); } - this.key = builder.key == null ? null : new JsonPrimitive(builder.key); - this.ip = builder.ip == null ? null : new JsonPrimitive(builder.ip); - this.country = builder.country == null ? null : new JsonPrimitive(builder.country.getAlpha2()); - this.secondary = builder.secondary == null ? null : new JsonPrimitive(builder.secondary); - this.firstName = builder.firstName == null ? null : new JsonPrimitive(builder.firstName); - this.lastName = builder.lastName == null ? null : new JsonPrimitive(builder.lastName); - this.email = builder.email == null ? null : new JsonPrimitive(builder.email); - 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.key = LDValue.of(builder.key); + this.ip = LDValue.of(builder.ip); + this.country = builder.country == null ? LDValue.ofNull() : LDValue.of(builder.country.getAlpha2()); + this.secondary = LDValue.of(builder.secondary); + this.firstName = LDValue.of(builder.firstName); + this.lastName = LDValue.of(builder.lastName); + this.email = LDValue.of(builder.email); + this.name = LDValue.of(builder.name); + this.avatar = LDValue.of(builder.avatar); + this.anonymous = builder.anonymous == null ? LDValue.ofNull() : LDValue.of(builder.anonymous); this.custom = builder.custom == null ? null : ImmutableMap.copyOf(builder.custom); this.privateAttributeNames = builder.privateAttrNames == null ? null : ImmutableSet.copyOf(builder.privateAttrNames); } @@ -79,12 +80,12 @@ protected LDUser(Builder builder) { * @param key a {@code String} that uniquely identifies a user */ public LDUser(String key) { - this.key = new JsonPrimitive(key); + this.key = LDValue.of(key); this.custom = null; this.privateAttributeNames = null; } - protected JsonElement getValueForEvaluation(String attribute) { + protected LDValue getValueForEvaluation(String attribute) { // Don't use Enum.valueOf because we don't want to trigger unnecessary exceptions for (UserAttribute builtIn: UserAttribute.values()) { if (builtIn.name().equals(attribute)) { @@ -94,59 +95,55 @@ protected JsonElement getValueForEvaluation(String attribute) { return getCustom(attribute); } - JsonPrimitive getKey() { + LDValue getKey() { return key; } String getKeyAsString() { - if (key == null) { - return ""; - } else { - return key.getAsString(); - } + return key.stringValue(); } - JsonPrimitive getIp() { + LDValue getIp() { return ip; } - JsonPrimitive getCountry() { + LDValue getCountry() { return country; } - JsonPrimitive getSecondary() { + LDValue getSecondary() { return secondary; } - JsonPrimitive getName() { + LDValue getName() { return name; } - JsonPrimitive getFirstName() { + LDValue getFirstName() { return firstName; } - JsonPrimitive getLastName() { + LDValue getLastName() { return lastName; } - JsonPrimitive getEmail() { + LDValue getEmail() { return email; } - JsonPrimitive getAvatar() { + LDValue getAvatar() { return avatar; } - JsonPrimitive getAnonymous() { + LDValue getAnonymous() { return anonymous; } - JsonElement getCustom(String key) { + LDValue getCustom(String key) { if (custom != null) { return custom.get(key); } - return null; + return LDValue.ofNull(); } @Override @@ -156,7 +153,7 @@ public boolean equals(Object o) { LDUser ldUser = (LDUser) o; - return Objects.equals(key, ldUser.key) && + return Objects.equals(key, ldUser.key) && Objects.equals(secondary, ldUser.secondary) && Objects.equals(ip, ldUser.ip) && Objects.equals(email, ldUser.email) && @@ -198,47 +195,47 @@ public void write(JsonWriter out, LDUser user) throws IOException { // The key can never be private out.name("key").value(user.getKeyAsString()); - if (user.getSecondary() != null) { + if (!user.getSecondary().isNull()) { if (!checkAndAddPrivate("secondary", user, privateAttributeNames)) { - out.name("secondary").value(user.getSecondary().getAsString()); + out.name("secondary").value(user.getSecondary().stringValue()); } } - if (user.getIp() != null) { + if (!user.getIp().isNull()) { if (!checkAndAddPrivate("ip", user, privateAttributeNames)) { - out.name("ip").value(user.getIp().getAsString()); + out.name("ip").value(user.getIp().stringValue()); } } - if (user.getEmail() != null) { + if (!user.getEmail().isNull()) { if (!checkAndAddPrivate("email", user, privateAttributeNames)) { - out.name("email").value(user.getEmail().getAsString()); + out.name("email").value(user.getEmail().stringValue()); } } - if (user.getName() != null) { + if (!user.getName().isNull()) { if (!checkAndAddPrivate("name", user, privateAttributeNames)) { - out.name("name").value(user.getName().getAsString()); + out.name("name").value(user.getName().stringValue()); } } - if (user.getAvatar() != null) { + if (!user.getAvatar().isNull()) { if (!checkAndAddPrivate("avatar", user, privateAttributeNames)) { - out.name("avatar").value(user.getAvatar().getAsString()); + out.name("avatar").value(user.getAvatar().stringValue()); } } - if (user.getFirstName() != null) { + if (!user.getFirstName().isNull()) { if (!checkAndAddPrivate("firstName", user, privateAttributeNames)) { - out.name("firstName").value(user.getFirstName().getAsString()); + out.name("firstName").value(user.getFirstName().stringValue()); } } - if (user.getLastName() != null) { + if (!user.getLastName().isNull()) { if (!checkAndAddPrivate("lastName", user, privateAttributeNames)) { - out.name("lastName").value(user.getLastName().getAsString()); + out.name("lastName").value(user.getLastName().stringValue()); } } - if (user.getAnonymous() != null) { - out.name("anonymous").value(user.getAnonymous().getAsBoolean()); + if (!user.getAnonymous().isNull()) { + out.name("anonymous").value(user.getAnonymous().booleanValue()); } - if (user.getCountry() != null) { + if (!user.getCountry().isNull()) { if (!checkAndAddPrivate("country", user, privateAttributeNames)) { - out.name("country").value(user.getCountry().getAsString()); + out.name("country").value(user.getCountry().stringValue()); } } writeCustomAttrs(out, user, privateAttributeNames); @@ -272,7 +269,7 @@ private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAt if (user.custom == null) { return; } - for (Map.Entry entry : user.custom.entrySet()) { + for (Map.Entry entry : user.custom.entrySet()) { if (!checkAndAddPrivate(entry.getKey(), user, privateAttributeNames)) { if (!beganObject) { out.name("custom"); @@ -280,7 +277,7 @@ private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAt beganObject = true; } out.name(entry.getKey()); - gson.toJson(entry.getValue(), JsonElement.class, out); + gson.toJson(entry.getValue(), LDValue.class, out); } } if (beganObject) { @@ -316,7 +313,7 @@ public static class Builder { private String avatar; private Boolean anonymous; private LDCountryCode country; - private Map custom; + private Map custom; private Set privateAttrNames; /** @@ -334,23 +331,18 @@ public Builder(String key) { * @param user an existing {@code LDUser} */ public Builder(LDUser user) { - JsonPrimitive userKey = user.getKey(); - if (userKey.isJsonNull()) { - this.key = null; - } else { - this.key = user.getKeyAsString(); - } - this.secondary = user.getSecondary() != null ? user.getSecondary().getAsString() : null; - this.ip = user.getIp() != null ? user.getIp().getAsString() : null; - this.firstName = user.getFirstName() != null ? user.getFirstName().getAsString() : null; - this.lastName = user.getLastName() != null ? user.getLastName().getAsString() : null; - this.email = user.getEmail() != null ? user.getEmail().getAsString() : null; - this.name = user.getName() != null ? user.getName().getAsString() : null; - 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 = user.custom == null ? null : new HashMap<>(user.custom); - this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); + this.key = user.getKey().stringValue(); + this.secondary = user.getSecondary().stringValue(); + this.ip = user.getIp().stringValue(); + this.firstName = user.getFirstName().stringValue(); + this.lastName = user.getLastName().stringValue(); + this.email = user.getEmail().stringValue(); + this.name = user.getName().stringValue(); + this.avatar = user.getAvatar().stringValue(); + this.anonymous = user.getAnonymous().isNull() ? null : user.getAnonymous().booleanValue(); + this.country = user.getCountry().isNull() ? null : LDCountryCode.valueOf(user.getCountry().stringValue()); + this.custom = user.custom == null ? null : new HashMap<>(user.custom); + this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); } /** @@ -637,15 +629,16 @@ public Builder custom(String k, Boolean b) { } /** - * Add a custom attribute whose value can be any JSON type. When set to one of the + * Add a custom attribute whose value can be any JSON type, using {@link LDValue}. When set to one of the * built-in * user attribute keys, this custom attribute will be ignored. * * @param k the key for the custom attribute * @param v the value for the custom attribute * @return the builder + * @since 4.8.0 */ - public Builder custom(String k, JsonElement v) { + public Builder custom(String k, LDValue v) { checkCustomAttribute(k); if (k != null && v != null) { if (custom == null) { @@ -655,7 +648,22 @@ public Builder custom(String k, JsonElement v) { } return this; } - + + /** + * Add a custom attribute whose value can be any JSON type. This is equivalent to {@link #custom(String, LDValue)} + * but uses the Gson type {@link JsonElement}. Using {@link LDValue} is preferred; the Gson types may be removed + * from the public API in the future. + * + * @param k the key for the custom attribute + * @param v the value for the custom attribute + * @return the builder + * @deprecated Use {@link #custom(String, LDValue)}. + */ + @Deprecated + public Builder custom(String k, JsonElement v) { + return custom(k, LDValue.unsafeFromJsonElement(v)); + } + /** * Add a list of {@link java.lang.String}-valued custom attributes. When set to one of the * built-in @@ -767,7 +775,25 @@ public Builder privateCustom(String k, Boolean b) { * @param k the key for the custom attribute * @param v the value for the custom attribute * @return the builder + * @since 4.8.0 + */ + public Builder privateCustom(String k, LDValue v) { + addPrivate(k); + return custom(k, v); + } + + /** + * Add a custom attribute of any JSON type, that will not be sent back to LaunchDarkly. + * When set to one of the + * built-in + * user attribute keys, this custom attribute will be ignored. + * + * @param k the key for the custom attribute + * @param v the value for the custom attribute + * @return the builder + * @deprecated Use {@link #privateCustom(String, LDValue)}. */ + @Deprecated public Builder privateCustom(String k, JsonElement v) { addPrivate(k); return custom(k, v); diff --git a/src/main/java/com/launchdarkly/client/OperandType.java b/src/main/java/com/launchdarkly/client/OperandType.java index 6cdf77e6e..861d155d4 100644 --- a/src/main/java/com/launchdarkly/client/OperandType.java +++ b/src/main/java/com/launchdarkly/client/OperandType.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; /** * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors @@ -13,27 +14,21 @@ enum OperandType { date, semVer; - public static OperandType bestGuess(JsonPrimitive value) { + public static OperandType bestGuess(LDValue value) { return value.isNumber() ? number : string; } - public Object getValueAsType(JsonPrimitive value) { + public Object getValueAsType(LDValue value) { switch (this) { case string: - return value.getAsString(); + return value.stringValue(); case number: - if (value.isNumber()) { - try { - return value.getAsDouble(); - } catch (NumberFormatException e) { - } - } - return null; + return value.isNumber() ? Double.valueOf(value.doubleValue()) : null; case date: return Util.jsonPrimitiveToDateTime(value); case semVer: try { - return SemanticVersion.parse(value.getAsString(), true); + return SemanticVersion.parse(value.stringValue(), true); } catch (SemanticVersion.InvalidVersionException e) { return null; } diff --git a/src/main/java/com/launchdarkly/client/Operator.java b/src/main/java/com/launchdarkly/client/Operator.java index c295ee990..ff8e61ad1 100644 --- a/src/main/java/com/launchdarkly/client/Operator.java +++ b/src/main/java/com/launchdarkly/client/Operator.java @@ -3,6 +3,8 @@ import java.util.regex.Pattern; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.client.value.LDValueType; /** * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors @@ -12,7 +14,7 @@ enum Operator { in { @Override - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { if (uValue.equals(cValue)) { return true; } @@ -25,83 +27,86 @@ public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { }, endsWith { @Override - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { - return uValue.isString() && cValue.isString() && uValue.getAsString().endsWith(cValue.getAsString()); + public boolean apply(LDValue uValue, LDValue cValue) { + return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && + uValue.stringValue().endsWith(cValue.stringValue()); } }, startsWith { @Override - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { - return uValue.isString() && cValue.isString() && uValue.getAsString().startsWith(cValue.getAsString()); + public boolean apply(LDValue uValue, LDValue cValue) { + return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && + uValue.stringValue().startsWith(cValue.stringValue()); } }, matches { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { - return uValue.isString() && cValue.isString() && - Pattern.compile(cValue.getAsString()).matcher(uValue.getAsString()).find(); + public boolean apply(LDValue uValue, LDValue cValue) { + return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && + Pattern.compile(cValue.stringValue()).matcher(uValue.stringValue()).find(); } }, contains { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { - return uValue.isString() && cValue.isString() && uValue.getAsString().contains(cValue.getAsString()); + public boolean apply(LDValue uValue, LDValue cValue) { + return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && + uValue.stringValue().contains(cValue.stringValue()); } }, lessThan { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.number); } }, lessThanOrEqual { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.LTE, uValue, cValue, OperandType.number); } }, greaterThan { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.number); } }, greaterThanOrEqual { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.GTE, uValue, cValue, OperandType.number); } }, before { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.date); } }, after { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.date); } }, semVerEqual { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.EQ, uValue, cValue, OperandType.semVer); } }, semVerLessThan { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.LT, uValue, cValue, OperandType.semVer); } }, semVerGreaterThan { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { return compareValues(ComparisonOp.GT, uValue, cValue, OperandType.semVer); } }, segmentMatch { - public boolean apply(JsonPrimitive uValue, JsonPrimitive cValue) { + public boolean apply(LDValue uValue, LDValue cValue) { // We shouldn't call apply() for this operator, because it is really implemented in // Clause.matchesUser(). return false; } }; - abstract boolean apply(JsonPrimitive uValue, JsonPrimitive cValue); + abstract boolean apply(LDValue uValue, LDValue cValue); - private static boolean compareValues(ComparisonOp op, JsonPrimitive uValue, JsonPrimitive cValue, OperandType asType) { + private static boolean compareValues(ComparisonOp op, LDValue uValue, LDValue cValue, OperandType asType) { Object uValueObj = asType.getValueAsType(uValue); Object cValueObj = asType.getValueAsType(cValue); return uValueObj != null && cValueObj != null && op.apply(uValueObj, cValueObj); diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index 39d20e7cd..daf3cf000 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -9,6 +9,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; /** * A decorated {@link InMemoryFeatureStore} which provides functionality to create (or override) true or false feature flags for all users. @@ -19,10 +20,7 @@ */ @Deprecated public class TestFeatureStore extends InMemoryFeatureStore { - static List TRUE_FALSE_VARIATIONS = Arrays.asList( - (JsonElement) (new JsonPrimitive(true)), - (JsonElement) (new JsonPrimitive(false)) - ); + static List TRUE_FALSE_VARIATIONS = Arrays.asList(LDValue.of(true), LDValue.of(false)); private AtomicInteger version = new AtomicInteger(0); private volatile boolean initializedForTests = false; @@ -107,7 +105,7 @@ public FeatureFlag setJsonValue(String key, JsonElement value) { FeatureFlag newFeature = new FeatureFlagBuilder(key) .on(false) .offVariation(0) - .variations(Arrays.asList(value)) + .variations(Arrays.asList(LDValue.fromJsonElement(value))) .version(version.incrementAndGet()) .build(); upsert(FEATURES, newFeature); diff --git a/src/main/java/com/launchdarkly/client/UserAttribute.java b/src/main/java/com/launchdarkly/client/UserAttribute.java index 40f3ce44f..1da2e02a5 100644 --- a/src/main/java/com/launchdarkly/client/UserAttribute.java +++ b/src/main/java/com/launchdarkly/client/UserAttribute.java @@ -1,55 +1,55 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; enum UserAttribute { key { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getKey(); } }, secondary { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return null; //Not used for evaluation. } }, ip { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getIp(); } }, email { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getEmail(); } }, avatar { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getAvatar(); } }, firstName { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getFirstName(); } }, lastName { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getLastName(); } }, name { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getName(); } }, country { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getCountry(); } }, anonymous { - JsonElement get(LDUser user) { + LDValue get(LDUser user) { return user.getAnonymous(); } }; @@ -60,5 +60,5 @@ JsonElement get(LDUser user) { * @param user * @return */ - abstract JsonElement get(LDUser user); + abstract LDValue get(LDUser user); } diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 78ea7b759..8de1d5593 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -1,6 +1,9 @@ package com.launchdarkly.client; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.client.value.LDValueType; + import org.joda.time.DateTime; import org.joda.time.DateTimeZone; @@ -12,13 +15,12 @@ class Util { * @param maybeDate wraps either a nubmer or a string that may contain a valid timestamp. * @return null if input is not a valid format. */ - static DateTime jsonPrimitiveToDateTime(JsonPrimitive maybeDate) { + static DateTime jsonPrimitiveToDateTime(LDValue maybeDate) { if (maybeDate.isNumber()) { - long millis = maybeDate.getAsLong(); - return new DateTime(millis); - } else if (maybeDate.isString()) { + return new DateTime((long)maybeDate.doubleValue()); + } else if (maybeDate.getType() == LDValueType.STRING) { try { - return new DateTime(maybeDate.getAsString(), DateTimeZone.UTC); + return new DateTime(maybeDate.stringValue(), DateTimeZone.UTC); } catch (Throwable t) { return null; } diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java index 41f58a676..c9213e915 100644 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ b/src/main/java/com/launchdarkly/client/VariationOrRollout.java @@ -1,7 +1,8 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; + import org.apache.commons.codec.digest.DigestUtils; import java.util.List; @@ -44,11 +45,11 @@ Integer variationIndexForUser(LDUser user, String key, String salt) { } static float bucketUser(LDUser user, String key, String attr, String salt) { - JsonElement userValue = user.getValueForEvaluation(attr); + LDValue userValue = user.getValueForEvaluation(attr); String idHash = getBucketableStringValue(userValue); if (idHash != null) { - if (user.getSecondary() != null) { - idHash = idHash + "." + user.getSecondary().getAsString(); + if (!user.getSecondary().isNull()) { + idHash = idHash + "." + user.getSecondary().stringValue(); } String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15); long longVal = Long.parseLong(hash, 16); @@ -57,19 +58,15 @@ static float bucketUser(LDUser user, String key, String attr, String salt) { return 0F; } - private static String getBucketableStringValue(JsonElement userValue) { - if (userValue != null && userValue.isJsonPrimitive()) { - if (userValue.getAsJsonPrimitive().isString()) { - return userValue.getAsString(); - } - if (userValue.getAsJsonPrimitive().isNumber()) { - Number n = userValue.getAsJsonPrimitive().getAsNumber(); - if (n instanceof Integer) { - return userValue.getAsString(); - } - } + private static String getBucketableStringValue(LDValue userValue) { + switch (userValue.getType()) { + case STRING: + return userValue.stringValue(); + case NUMBER: + return userValue.isInt() ? String.valueOf(userValue.intValue()) : null; + default: + return null; } - return null; } static class Rollout { diff --git a/src/main/java/com/launchdarkly/client/VariationType.java b/src/main/java/com/launchdarkly/client/VariationType.java deleted file mode 100644 index c222d57a4..000000000 --- a/src/main/java/com/launchdarkly/client/VariationType.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.launchdarkly.client; - - -import com.google.gson.JsonElement; - -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 result.getAsBoolean(); - } - throw new EvaluationException("Feature flag evaluation expected result as boolean type, but got non-boolean type."); - } - }; - - static VariationType Integer = new VariationType() { - Integer coerceValue(JsonElement result) throws EvaluationException { - if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isNumber()) { - return result.getAsInt(); - } - throw new EvaluationException("Feature flag evaluation expected result as number type, but got non-number type."); - } - }; - - static VariationType Double = new VariationType() { - Double coerceValue(JsonElement result) throws EvaluationException { - if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isNumber()) { - return result.getAsDouble(); - } - throw new EvaluationException("Feature flag evaluation expected result as number type, but got non-number type."); - } - }; - - static VariationType String = new VariationType() { - String coerceValue(JsonElement result) throws EvaluationException { - if (result.isJsonPrimitive() && result.getAsJsonPrimitive().isString()) { - return result.getAsString(); - } - throw new EvaluationException("Feature flag evaluation expected result as string type, but got non-string type."); - } - }; - - static VariationType Json = new VariationType() { - JsonElement coerceValue(JsonElement result) throws EvaluationException { - return result; - } - }; -} diff --git a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java new file mode 100644 index 000000000..2dc386c69 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java @@ -0,0 +1,32 @@ +package com.launchdarkly.client.value; + +import com.google.common.collect.ImmutableList; + +/** + * A builder created by {@link LDValue#buildArray()}. Builder methods are not thread-safe. + * + * @since 4.8.0 + */ +public final class ArrayBuilder { + private final ImmutableList.Builder builder = ImmutableList.builder(); + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(LDValue value) { + builder.add(value); + return this; + } + + /** + * Returns an array containing the builder's current elements. Subsequent changes to the builder + * will not affect this value (it uses copy-on-write logic, so the previous values will only be + * copied to a new list if you continue to add elements after calling {@link #build()}. + * @return an {@link LDValue} that is an array + */ + public LDValue build() { + return LDValueArray.fromList(builder.build()); + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java new file mode 100644 index 000000000..62bb5f3b5 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -0,0 +1,407 @@ +package com.launchdarkly.client.value; + +import com.google.common.collect.ImmutableList; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.client.LDClientInterface; +import com.launchdarkly.client.LDUser; + +import java.io.IOException; + +/** + * An immutable instance of any data type that is allowed in JSON. + *

    + * This is used with the client's {@link LDClientInterface#jsonValueVariation(String, LDUser, LDValue)} + * method, and is also used internally to hold feature flag values. + *

    + * While the LaunchDarkly SDK uses Gson for JSON parsing, some of the Gson value types (object + * and array) are mutable. In contexts where it is important for data to remain immutable after + * it is created, these values are represented with {@link LDValue} instead. It is easily + * convertible to primitive types and provides array element/object property accessors. + * + * @since 4.8.0 + */ +@JsonAdapter(LDValueTypeAdapter.class) +public abstract class LDValue { + static final Gson gson = new Gson(); + + private boolean haveComputedJsonElement = false; + private JsonElement computedJsonElement = null; + + /** + * Returns the same value if non-null, or {@link #ofNull()} if null. + * + * @param value an {@link LDValue} or null + * @return an {@link LDValue} which will never be a null reference + */ + public static LDValue normalize(LDValue value) { + return value == null ? ofNull() : value; + } + + /** + * Returns an instance for a null value. The same instance is always used. + * + * @return an LDValue containing null + */ + public static LDValue ofNull() { + return LDValueNull.INSTANCE; + } + + /** + * Returns an instance for a boolean value. The same instances for {@code true} and {@code false} + * are always used. + * + * @param value a boolean value + * @return an LDValue containing that value + */ + public static LDValue of(boolean value) { + return LDValueBool.fromBoolean(value); + } + + /** + * Returns an instance for a numeric value. + * + * @param value an integer numeric value + * @return an LDValue containing that value + */ + public static LDValue of(int value) { + return LDValueNumber.fromDouble(value); + } + + /** + * Returns an instance for a numeric value. + * + * @param value a floating-point numeric value + * @return an LDValue containing that value + */ + public static LDValue of(float value) { + return LDValueNumber.fromDouble(value); + } + + /** + * Returns an instance for a numeric value. + * + * @param value a floating-point numeric value + * @return an LDValue containing that value + */ + public static LDValue of(double value) { + return LDValueNumber.fromDouble(value); + } + + /** + * Returns an instance for a string value (or a null). + * + * @param value a nullable String reference + * @return an LDValue containing a string, or {@link #ofNull()} if the value was null. + */ + public static LDValue of(String value) { + return value == null ? ofNull() : LDValueString.fromString(value); + } + + /** + * Starts building an array value. + *

    +   *     LDValue arrayOfInts = LDValue.buildArray().add(LDValue.int(1), LDValue.int(2)).build():
    +   * 
    + * @return an {@link ArrayBuilder} + */ + public static ArrayBuilder buildArray() { + return new ArrayBuilder(); + } + + /** + * Starts building an object value. + *
    +   *     LDValue objectVal = LDValue.buildObject().put("key", LDValue.int(1)).build():
    +   * 
    + * @return an {@link ObjectBuilder} + */ + public static ObjectBuilder buildObject() { + return new ObjectBuilder(); + } + + /** + * Returns an instance based on a {@link JsonElement} value. If the value is a complex type, it is + * deep-copied; primitive types are used as is. + * + * @param value a nullable {@link JsonElement} reference + * @return an LDValue containing the specified value, or {@link #ofNull()} if the value was null. + * @deprecated The Gson types may be removed from the public API at some point; it is preferable to + * use factory methods like {@link #of(boolean)}. + */ + @Deprecated + public static LDValue fromJsonElement(JsonElement value) { + return value == null || value.isJsonNull() ? ofNull() : LDValueJsonElement.copyValue(value); + } + + /** + * Returns an instance that wraps an existing {@link JsonElement} value without copying it. This + * method exists only to support deprecated SDK methods where a {@link JsonElement} is needed, to + * avoid the inefficiency of a deep-copy; application code should not use it, since it can break + * the immutability contract of {@link LDValue}. + * + * @param value a nullable {@link JsonElement} reference + * @return an LDValue containing the specified value, or {@link #ofNull()} if the value was null. + * @deprecated This method will be removed in a future version. Application code should use + * {@link #fromJsonElement(JsonElement)} or, preferably, factory methods like {@link #of(boolean)}. + */ + @Deprecated + public static LDValue unsafeFromJsonElement(JsonElement value) { + return value == null || value.isJsonNull() ? ofNull() : LDValueJsonElement.wrapUnsafeValue(value); + } + + /** + * Gets the JSON type for this value. + * + * @return the appropriate {@link LDValueType} + */ + public abstract LDValueType getType(); + + /** + * Tests whether this value is a null. + * + * @return {@code true} if this is a null value + */ + public boolean isNull() { + return false; + } + + /** + * Returns this value as a boolean if it is explicitly a boolean. Otherwise returns {@code false}. + * + * @return a boolean + */ + public boolean booleanValue() { + return false; + } + + /** + * Tests whether this value is a number (not a numeric string). + * + * @return {@code true} if this is a numeric value + */ + public boolean isNumber() { + return false; + } + + /** + * Tests whether this value is a number that is also an integer. + *

    + * JSON does not have separate types for integer and floating-point values; they are both just + * numbers. This method returns true if and only if the actual numeric value has no fractional + * component, so {@code LDValue.of(2).isInt()} and {@code LDValue.of(2.0f).isInt()} are both true. + * + * @return {@code true} if this is an integer value + */ + public boolean isInt() { + return false; + } + + /** + * Returns this value as an {@code int} if it is numeric. Returns zero for all non-numeric values. + *

    + * If the value is a number but not an integer, it will be rounded toward zero (truncated). + * This is consistent with Java casting behavior, and with most other LaunchDarkly SDKs. + * + * @return an {@code int} value + */ + public int intValue() { + return 0; + } + + /** + * Returns this value as a {@code float} if it is numeric. Returns zero for all non-numeric values. + * + * @return a {@code float} value + */ + public float floatValue() { + return 0; + } + + /** + * Returns this value as a {@code double} if it is numeric. Returns zero for all non-numeric values. + * + * @return a {@code double} value + */ + public double doubleValue() { + return 0; + } + + /** + * Returns this value as a {@code String} if it is a string. Returns {@code null} for all non-string values. + * + * @return a nullable string value + */ + public String stringValue() { + return null; + } + + /** + * Returns the number of elements in an array or object. Returns zero for all other types. + * + * @return the number of array elements or object properties + */ + public int size() { + return 0; + } + + /** + * Enumerates the property names in an object. Returns an empty iterable for all other types. + * + * @return the property names + */ + public Iterable keys() { + return ImmutableList.of(); + } + + /** + * Enumerates the values in an array or object. Returns an empty iterable for all other types. + * + * @return an iterable of {@link LDValue} values + */ + public Iterable values() { + return ImmutableList.of(); + } + + /** + * Returns an array element by index. Returns {@link #ofNull()} if this is not an array or if the + * index is out of range (will never throw an exception). + * + * @param index the array index + * @return the element value or {@link #ofNull()} + */ + public LDValue get(int index) { + return ofNull(); + } + + /** + * Returns an object property by name. Returns {@link #ofNull()} if this is not an object or if the + * key is not found (will never throw an exception). + * + * @param name the property name + * @return the property value or {@link #ofNull()} + */ + public LDValue get(String name) { + return ofNull(); + } + + /** + * Converts this value to its JSON serialization. + * + * @return a JSON string + */ + public String toJsonString() { + return gson.toJson(this); + } + + /** + * Converts this value to a {@link JsonElement}. If the value is a complex type, it is deep-copied + * deep-copied, so modifying the return value will not affect the {@link LDValue}. + * + * @return a {@link JsonElement}, or {@code null} if the value is a null + * @deprecated The Gson types may be removed from the public API at some point; it is preferable to + * use getters like {@link #booleanValue()} and {@link #getType()}. + */ + public JsonElement asJsonElement() { + return LDValueJsonElement.deepCopy(asUnsafeJsonElement()); + } + + /** + * Returns the original {@link JsonElement} if the value was created from one, otherwise converts the + * value to a {@link JsonElement}. This method exists only to support deprecated SDK methods where a + * {@link JsonElement} is needed, to avoid the inefficiency of a deep-copy; application code should not + * use it, since it can break the immutability contract of {@link LDValue}. + * + * @return a {@link JsonElement}, or {@code null} if the value is a null + * @deprecated This method will be removed in a future version. Application code should always use + * {@link #asJsonElement()}. + */ + @Deprecated + public JsonElement asUnsafeJsonElement() { + // Lazily compute this value + synchronized (this) { + if (!haveComputedJsonElement) { + computedJsonElement = computeJsonElement(); + haveComputedJsonElement = true; + } + return computedJsonElement; + } + } + + abstract JsonElement computeJsonElement(); + + abstract void write(JsonWriter writer) throws IOException; + + static boolean isInteger(double value) { + return value == (double)((int)value); + } + + @Override + public String toString() { + return toJsonString(); + } + + // equals() and hashCode() are defined here in the base class so that we don't have to worry about + // whether a value is stored as LDValueJsonElement vs. one of our own primitive types. + + @Override + public boolean equals(Object o) { + if (o instanceof LDValue) { + LDValue other = (LDValue)o; + if (getType() == other.getType()) { + switch (getType()) { + case NULL: return other.isNull(); + case BOOLEAN: return booleanValue() == other.booleanValue(); + case NUMBER: return doubleValue() == other.doubleValue(); + case STRING: return stringValue().equals(other.stringValue()); + case ARRAY: + if (size() != other.size()) { + return false; + } + for (int i = 0; i < size(); i++) { + if (!get(i).equals(other.get(i))) { + return false; + } + } + return true; + case OBJECT: + if (size() != other.size()) { + return false; + } + for (String name: keys()) { + if (!get(name).equals(other.get(name))) { + return false; + } + } + return true; + } + } + } + return false; + } + + @Override + public int hashCode() { + switch (getType()) { + case NULL: return 0; + case BOOLEAN: return booleanValue() ? 1 : 0; + case NUMBER: return intValue(); + case STRING: return stringValue().hashCode(); + case ARRAY: + int ah = 0; + for (LDValue v: values()) { + ah = ah * 31 + v.hashCode(); + } + return ah; + case OBJECT: + int oh = 0; + for (String name: keys()) { + oh = (oh * 31 + name.hashCode()) * 31 + get(name).hashCode(); + } + return oh; + default: return 0; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueArray.java b/src/main/java/com/launchdarkly/client/value/LDValueArray.java new file mode 100644 index 000000000..250863121 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueArray.java @@ -0,0 +1,64 @@ +package com.launchdarkly.client.value; + +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueArray extends LDValue { + private static final LDValueArray EMPTY = new LDValueArray(ImmutableList.of()); + private final ImmutableList list; + + static LDValueArray fromList(ImmutableList list) { + return list.isEmpty() ? EMPTY : new LDValueArray(list); + } + + private LDValueArray(ImmutableList list) { + this.list = list; + } + + public LDValueType getType() { + return LDValueType.ARRAY; + } + + @Override + public int size() { + return list.size(); + } + + @Override + public Iterable values() { + return list; + } + + @Override + public LDValue get(int index) { + if (index >= 0 && index < list.size()) { + return list.get(index); + } + return ofNull(); + } + + @Override + void write(JsonWriter writer) throws IOException { + writer.beginArray(); + for (LDValue v: list) { + v.write(writer); + } + writer.endArray(); + } + + @Override + @SuppressWarnings("deprecation") + JsonElement computeJsonElement() { + JsonArray a = new JsonArray(); + for (LDValue item: list) { + a.add(item.asUnsafeJsonElement()); + } + return a; + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueBool.java b/src/main/java/com/launchdarkly/client/value/LDValueBool.java new file mode 100644 index 000000000..321361353 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueBool.java @@ -0,0 +1,50 @@ +package com.launchdarkly.client.value; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueBool extends LDValue { + private static final LDValueBool TRUE = new LDValueBool(true); + private static final LDValueBool FALSE = new LDValueBool(false); + private static final JsonElement JSON_TRUE = new JsonPrimitive(true); + private static final JsonElement JSON_FALSE = new JsonPrimitive(false); + + private final boolean value; + + static LDValueBool fromBoolean(boolean value) { + return value ? TRUE : FALSE; + } + + private LDValueBool(boolean value) { + this.value = value; + } + + public LDValueType getType() { + return LDValueType.BOOLEAN; + } + + @Override + public boolean booleanValue() { + return value; + } + + @Override + public String toJsonString() { + return value ? "true" : "false"; + } + + @Override + void write(JsonWriter writer) throws IOException { + writer.value(value); + } + + @Override + JsonElement computeJsonElement() { + return value ? JSON_TRUE : JSON_FALSE; + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java new file mode 100644 index 000000000..75d4141f0 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java @@ -0,0 +1,195 @@ +package com.launchdarkly.client.value; + +import com.google.common.base.Function; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.Map; +import java.util.Map.Entry; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueJsonElement extends LDValue { + private final JsonElement value; + private final LDValueType type; + + static LDValueJsonElement copyValue(JsonElement value) { + return new LDValueJsonElement(deepCopy(value)); + } + + static LDValueJsonElement wrapUnsafeValue(JsonElement value) { + return new LDValueJsonElement(value); + } + + LDValueJsonElement(JsonElement value) { + this.value = value; + type = typeFromValue(value); + } + + private static LDValueType typeFromValue(JsonElement value) { + if (value != null) { + if (value.isJsonPrimitive()) { + JsonPrimitive p = value.getAsJsonPrimitive(); + if (p.isBoolean()) { + return LDValueType.BOOLEAN; + } else if (p.isNumber()) { + return LDValueType.NUMBER; + } else if (p.isString()) { + return LDValueType.STRING; + } else { + return LDValueType.NULL; + } + } else if (value.isJsonArray()) { + return LDValueType.ARRAY; + } else if (value.isJsonObject()) { + return LDValueType.OBJECT; + } + } + return LDValueType.NULL; + } + + public LDValueType getType() { + return type; + } + + @Override + public boolean isNull() { + return value == null; + } + + @Override + public boolean booleanValue() { + return type == LDValueType.BOOLEAN && value.getAsBoolean(); + } + + @Override + public boolean isNumber() { + return type == LDValueType.NUMBER; + } + + @Override + public boolean isInt() { + return type == LDValueType.NUMBER && isInteger(value.getAsFloat()); + } + + @Override + public int intValue() { + return type == LDValueType.NUMBER ? (int)value.getAsFloat() : 0; // don't rely on their rounding behavior + } + + @Override + public float floatValue() { + return type == LDValueType.NUMBER ? value.getAsFloat() : 0; + } + + @Override + public double doubleValue() { + return type == LDValueType.NUMBER ? value.getAsDouble() : 0; + } + + @Override + public String stringValue() { + return type == LDValueType.STRING ? value.getAsString() : null; + } + + @Override + public int size() { + switch (type) { + case ARRAY: + return value.getAsJsonArray().size(); + case OBJECT: + return value.getAsJsonObject().size(); + default: return 0; + } + } + + @Override + public Iterable keys() { + if (type == LDValueType.OBJECT) { + return Iterables.transform(value.getAsJsonObject().entrySet(), new Function, String>() { + public String apply(Map.Entry e) { + return e.getKey(); + } + }); + } + return ImmutableList.of(); + } + + @SuppressWarnings("deprecation") + @Override + public Iterable values() { + switch (type) { + case ARRAY: + return Iterables.transform(value.getAsJsonArray(), new Function() { + public LDValue apply(JsonElement e) { + return unsafeFromJsonElement(e); + } + }); + case OBJECT: + return Iterables.transform(value.getAsJsonObject().entrySet(), new Function, LDValue>() { + public LDValue apply(Map.Entry e) { + return unsafeFromJsonElement(e.getValue()); + } + }); + default: return ImmutableList.of(); + } + } + + @SuppressWarnings("deprecation") + @Override + public LDValue get(int index) { + if (type == LDValueType.ARRAY) { + JsonArray a = value.getAsJsonArray(); + if (index >= 0 && index < a.size()) { + return unsafeFromJsonElement(a.get(index)); + } + } + return ofNull(); + } + + @SuppressWarnings("deprecation") + @Override + public LDValue get(String name) { + if (type == LDValueType.OBJECT) { + return unsafeFromJsonElement(value.getAsJsonObject().get(name)); + } + return ofNull(); + } + + @Override + void write(JsonWriter writer) throws IOException { + gson.toJson(value, writer); + } + + @Override + JsonElement computeJsonElement() { + return value; + } + + static JsonElement deepCopy(JsonElement value) { // deepCopy was added to Gson in 2.8.2 + if (value != null && !value.isJsonPrimitive()) { + if (value.isJsonArray()) { + JsonArray a = value.getAsJsonArray(); + JsonArray ret = new JsonArray(); + for (JsonElement e: a) { + ret.add(deepCopy(e)); + } + return ret; + } else if (value.isJsonObject()) { + JsonObject o = value.getAsJsonObject(); + JsonObject ret = new JsonObject(); + for (Entry e: o.entrySet()) { + ret.add(e.getKey(), deepCopy(e.getValue())); + } + return ret; + } + } + return value; + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueNull.java b/src/main/java/com/launchdarkly/client/value/LDValueNull.java new file mode 100644 index 000000000..00db72c34 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueNull.java @@ -0,0 +1,35 @@ +package com.launchdarkly.client.value; + +import com.google.gson.JsonElement; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueNull extends LDValue { + static final LDValueNull INSTANCE = new LDValueNull(); + + public LDValueType getType() { + return LDValueType.NULL; + } + + public boolean isNull() { + return true; + } + + @Override + public String toJsonString() { + return "null"; + } + + @Override + void write(JsonWriter writer) throws IOException { + writer.nullValue(); + } + + @Override + JsonElement computeJsonElement() { + return null; + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java b/src/main/java/com/launchdarkly/client/value/LDValueNumber.java new file mode 100644 index 000000000..3b5c81295 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueNumber.java @@ -0,0 +1,70 @@ +package com.launchdarkly.client.value; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueNumber extends LDValue { + private static final LDValueNumber ZERO = new LDValueNumber(0); + private final double value; + + static LDValueNumber fromDouble(double value) { + return value == 0 ? ZERO : new LDValueNumber(value); + } + + private LDValueNumber(double value) { + this.value = value; + } + + public LDValueType getType() { + return LDValueType.NUMBER; + } + + @Override + public boolean isNumber() { + return true; + } + + @Override + public boolean isInt() { + return isInteger(value); + } + + @Override + public int intValue() { + return (int)value; + } + + @Override + public float floatValue() { + return (float)value; + } + + @Override + public double doubleValue() { + return value; + } + + @Override + public String toJsonString() { + return isInt() ? String.valueOf(intValue()) : String.valueOf(value); + } + + @Override + void write(JsonWriter writer) throws IOException { + if (isInt()) { + writer.value(intValue()); + } else { + writer.value(value); + } + } + + @Override + JsonElement computeJsonElement() { + return new JsonPrimitive(value); + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueObject.java b/src/main/java/com/launchdarkly/client/value/LDValueObject.java new file mode 100644 index 000000000..eaceb5a7a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueObject.java @@ -0,0 +1,69 @@ +package com.launchdarkly.client.value; + +import com.google.common.collect.ImmutableMap; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; +import java.util.Map; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueObject extends LDValue { + private static final LDValueObject EMPTY = new LDValueObject(ImmutableMap.of()); + private final Map map; + + static LDValueObject fromMap(Map map) { + return map.isEmpty() ? EMPTY : new LDValueObject(map); + } + + private LDValueObject(Map map) { + this.map = map; + } + + public LDValueType getType() { + return LDValueType.OBJECT; + } + + @Override + public int size() { + return map.size(); + } + + @Override + public Iterable keys() { + return map.keySet(); + } + + @Override + public Iterable values() { + return map.values(); + } + + @Override + public LDValue get(String name) { + LDValue v = map.get(name); + return v == null ? ofNull() : v; + } + + @Override + void write(JsonWriter writer) throws IOException { + writer.beginObject(); + for (Map.Entry e: map.entrySet()) { + writer.name(e.getKey()); + e.getValue().write(writer); + } + writer.endObject(); + } + + @Override + @SuppressWarnings("deprecation") + JsonElement computeJsonElement() { + JsonObject o = new JsonObject(); + for (String key: map.keySet()) { + o.add(key, map.get(key).asUnsafeJsonElement()); + } + return o; + } +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueString.java b/src/main/java/com/launchdarkly/client/value/LDValueString.java new file mode 100644 index 000000000..e752ab888 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueString.java @@ -0,0 +1,41 @@ +package com.launchdarkly.client.value; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +@JsonAdapter(LDValueTypeAdapter.class) +final class LDValueString extends LDValue { + private static final LDValueString EMPTY = new LDValueString(""); + private final String value; + + static LDValueString fromString(String value) { + return value.isEmpty() ? EMPTY : new LDValueString(value); + } + + private LDValueString(String value) { + this.value = value; + } + + public LDValueType getType() { + return LDValueType.STRING; + } + + @Override + public String stringValue() { + return value; + } + + @Override + void write(JsonWriter writer) throws IOException { + writer.value(value); + } + + @Override + JsonElement computeJsonElement() { + return new JsonPrimitive(value); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/value/LDValueType.java b/src/main/java/com/launchdarkly/client/value/LDValueType.java new file mode 100644 index 000000000..d7e3ff7f4 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueType.java @@ -0,0 +1,34 @@ +package com.launchdarkly.client.value; + +/** + * Describes the type of an {@link LDValue}. These correspond to the standard types in JSON. + * + * @since 4.8.0 + */ +public enum LDValueType { + /** + * The value is null. + */ + NULL, + /** + * The value is a boolean. + */ + BOOLEAN, + /** + * The value is numeric. JSON does not have separate types for integers and floating-point values, + * but you can convert to either. + */ + NUMBER, + /** + * The value is a string. + */ + STRING, + /** + * The value is an array. + */ + ARRAY, + /** + * The value is an object (map). + */ + OBJECT +} diff --git a/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java b/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java new file mode 100644 index 000000000..72c50b960 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/LDValueTypeAdapter.java @@ -0,0 +1,53 @@ +package com.launchdarkly.client.value; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +final class LDValueTypeAdapter extends TypeAdapter{ + static final LDValueTypeAdapter INSTANCE = new LDValueTypeAdapter(); + + @Override + public LDValue read(JsonReader reader) throws IOException { + JsonToken token = reader.peek(); + switch (token) { + case BEGIN_ARRAY: + ArrayBuilder ab = LDValue.buildArray(); + reader.beginArray(); + while (reader.peek() != JsonToken.END_ARRAY) { + ab.add(read(reader)); + } + reader.endArray(); + return ab.build(); + case BEGIN_OBJECT: + ObjectBuilder ob = LDValue.buildObject(); + reader.beginObject(); + while (reader.peek() != JsonToken.END_OBJECT) { + String key = reader.nextName(); + LDValue value = read(reader); + ob.put(key, value); + } + reader.endObject(); + return ob.build(); + case BOOLEAN: + return LDValue.of(reader.nextBoolean()); + case NULL: + reader.nextNull(); + return LDValue.ofNull(); + case NUMBER: + return LDValue.of(reader.nextDouble()); + case STRING: + return LDValue.of(reader.nextString()); + default: + return null; + } + } + + @Override + public void write(JsonWriter writer, LDValue value) throws IOException { + value.write(writer); + } +} diff --git a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java new file mode 100644 index 000000000..0f08a0c93 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java @@ -0,0 +1,44 @@ +package com.launchdarkly.client.value; + +import java.util.HashMap; +import java.util.Map; + +/** + * A builder created by {@link LDValue#buildObject()}. Builder methods are not thread-safe. + * + * @since 4.8.0 + */ +public final class ObjectBuilder { + // Note that we're not using ImmutableMap here because we don't want to duplicate its semantics + // for duplicate keys (rather than overwriting the key *or* throwing an exception when you add it, + // it accepts it but then throws an exception when you call build()). So we have to reimplement + // the copy-on-write behavior. + private volatile Map builder = new HashMap(); + private volatile boolean copyOnWrite = false; + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, LDValue value) { + if (copyOnWrite) { + builder = new HashMap<>(builder); + copyOnWrite = false; + } + builder.put(key, value); + return this; + } + + /** + * Returns an object containing the builder's current elements. Subsequent changes to the builder + * will not affect this value (it uses copy-on-write logic, so the previous values will only be + * copied to a new map if you continue to add elements after calling {@link #build()}. + * @return an {@link LDValue} that is a JSON object + */ + public LDValue build() { + copyOnWrite = true; + return LDValueObject.fromMap(builder); + } +} diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 2d31a46b7..4cde9969e 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -3,9 +3,9 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.DefaultEventProcessor.EventDispatcher; +import com.launchdarkly.client.value.LDValue; import org.hamcrest.Matcher; import org.junit.Test; @@ -82,7 +82,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -102,7 +102,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { public void userIsFilteredInIndexEvent() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).allAttributesPrivate(true).build(); @@ -124,7 +124,7 @@ public void userIsFilteredInIndexEvent() throws Exception { public void featureEventCanContainInlineUser() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); @@ -145,7 +145,7 @@ public void featureEventCanContainInlineUser() throws Exception { public void userIsFilteredInFeatureEvent() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).allAttributesPrivate(true).build(); @@ -167,7 +167,7 @@ public void featureEventCanContainReason() throws Exception { 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); + EvaluationDetail.fromJsonValue(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -187,7 +187,7 @@ public void featureEventCanContainReason() throws Exception { public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), null); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); @@ -209,7 +209,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, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -231,7 +231,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, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -258,7 +258,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, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(resp1, resp2)) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -291,7 +291,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, - simpleEvaluation(1, new JsonPrimitive("value")), null); + simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(resp1, resp2)) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -320,11 +320,11 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).trackEvents(true).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); - JsonElement value = new JsonPrimitive("value"); + LDValue value = LDValue.of("value"); Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - simpleEvaluation(1, value), null); + simpleEvaluation(1, value), LDValue.ofNull()); Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - simpleEvaluation(1, value), null); + simpleEvaluation(1, value), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { @@ -346,9 +346,9 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except public void nonTrackedEventsAreSummarized() throws Exception { FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).build(); - JsonElement value = new JsonPrimitive("value"); - JsonElement default1 = new JsonPrimitive("default1"); - JsonElement default2 = new JsonPrimitive("default2"); + LDValue value = LDValue.of("value"); + LDValue default1 = LDValue.of("default1"); + LDValue default2 = LDValue.of("default2"); Event fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, simpleEvaluation(2, value), default1); Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, @@ -376,8 +376,7 @@ public void nonTrackedEventsAreSummarized() throws Exception { @SuppressWarnings("unchecked") @Test public void customEventIsQueuedWithUser() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); double metric = 1.5; Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, metric); @@ -395,8 +394,7 @@ public void customEventIsQueuedWithUser() throws Exception { @Test public void customEventCanContainInlineUser() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { @@ -412,8 +410,7 @@ public void customEventCanContainInlineUser() throws Exception { @Test public void userIsFilteredInCustomEvent() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { @@ -673,7 +670,7 @@ private Matcher isSummaryEvent(long startDate, long endDate) { ); } - private Matcher hasSummaryFlag(String key, JsonElement defaultVal, Matcher> counters) { + private Matcher hasSummaryFlag(String key, LDValue defaultVal, Matcher> counters) { return hasJsonProperty("features", hasJsonProperty(key, allOf( hasJsonProperty("default", defaultVal), @@ -681,7 +678,7 @@ private Matcher hasSummaryFlag(String key, JsonElement defaultVal, ))); } - private Matcher isSummaryEventCounter(FeatureFlag flag, Integer variation, JsonElement value, int count) { + private Matcher isSummaryEventCounter(FeatureFlag flag, Integer variation, LDValue value, int count) { return allOf( hasJsonProperty("variation", variation), hasJsonProperty("version", (double)flag.getVersion()), diff --git a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java index 29abebe73..c0e6f0aed 100644 --- a/src/test/java/com/launchdarkly/client/EventSummarizerTest.java +++ b/src/test/java/com/launchdarkly/client/EventSummarizerTest.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.launchdarkly.client.value.LDValue; + import org.junit.Test; import java.util.HashMap; @@ -71,14 +73,14 @@ public void summarizeEventIncrementsCounters() { FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(22).build(); String unknownFlagKey = "badkey"; Event event1 = eventFactory.newFeatureRequestEvent(flag1, user, - simpleEvaluation(1, js("value1")), js("default1")); + simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); Event event2 = eventFactory.newFeatureRequestEvent(flag1, user, - simpleEvaluation(2, js("value2")), js("default1")); + simpleEvaluation(2, LDValue.of("value2")), LDValue.of("default1")); Event event3 = eventFactory.newFeatureRequestEvent(flag2, user, - simpleEvaluation(1, js("value99")), js("default2")); + simpleEvaluation(1, LDValue.of("value99")), LDValue.of("default2")); Event event4 = eventFactory.newFeatureRequestEvent(flag1, user, - simpleEvaluation(1, js("value1")), js("default1")); - Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, js("default3"), EvaluationReason.ErrorKind.FLAG_NOT_FOUND); + simpleEvaluation(1, LDValue.of("value1")), LDValue.of("default1")); + Event event5 = eventFactory.newUnknownFeatureRequestEvent(unknownFlagKey, user, LDValue.of("default3"), EvaluationReason.ErrorKind.FLAG_NOT_FOUND); es.summarizeEvent(event1); es.summarizeEvent(event2); es.summarizeEvent(event3); @@ -88,13 +90,13 @@ public void summarizeEventIncrementsCounters() { Map expected = new HashMap<>(); expected.put(new EventSummarizer.CounterKey(flag1.getKey(), 1, flag1.getVersion()), - new EventSummarizer.CounterValue(2, js("value1"), js("default1"))); + new EventSummarizer.CounterValue(2, LDValue.of("value1"), LDValue.of("default1"))); expected.put(new EventSummarizer.CounterKey(flag1.getKey(), 2, flag1.getVersion()), - new EventSummarizer.CounterValue(1, js("value2"), js("default1"))); + new EventSummarizer.CounterValue(1, LDValue.of("value2"), LDValue.of("default1"))); expected.put(new EventSummarizer.CounterKey(flag2.getKey(), 1, flag2.getVersion()), - new EventSummarizer.CounterValue(1, js("value99"), js("default2"))); + new EventSummarizer.CounterValue(1, LDValue.of("value99"), LDValue.of("default2"))); expected.put(new EventSummarizer.CounterKey(unknownFlagKey, null, null), - new EventSummarizer.CounterValue(1, js("default3"), js("default3"))); + new EventSummarizer.CounterValue(1, LDValue.of("default3"), LDValue.of("default3"))); assertThat(data.counters, equalTo(expected)); } } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 1d3c800c5..5da885b00 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -3,22 +3,22 @@ import com.google.common.collect.ImmutableList; import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import org.junit.Before; import org.junit.Test; import java.util.Arrays; +import static com.launchdarkly.client.EvaluationDetail.fromJsonValue; import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; import static com.launchdarkly.client.TestUtil.fallthroughVariation; -import static com.launchdarkly.client.TestUtil.jbool; -import static com.launchdarkly.client.TestUtil.jint; -import static com.launchdarkly.client.TestUtil.js; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +@SuppressWarnings("javadoc") public class FeatureFlagTest { private static LDUser BASE_USER = new LDUser.Builder("x").build(); @@ -36,11 +36,11 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .on(false) .offVariation(1) .fallthrough(fallthroughVariation(0)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.off(), 1, js("off")), result.getDetails()); + assertEquals(fromJsonValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -49,11 +49,11 @@ public void flagReturnsNullIfFlagIsOffAndOffVariationIsUnspecified() throws Exce FeatureFlag f = new FeatureFlagBuilder("feature") .on(false) .fallthrough(fallthroughVariation(0)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.off(), null, null), result.getDetails()); + assertEquals(fromJsonValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -63,7 +63,7 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsTooHigh() throws Excepti .on(false) .offVariation(999) .fallthrough(fallthroughVariation(0)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); @@ -77,7 +77,7 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except .on(false) .offVariation(-1) .fallthrough(fallthroughVariation(0)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); @@ -91,11 +91,11 @@ public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exceptio .on(true) .offVariation(1) .fallthrough(fallthroughVariation(0)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(fromJsonValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -105,7 +105,7 @@ public void flagReturnsErrorIfFallthroughHasTooHighVariation() throws Exception .on(true) .offVariation(1) .fallthrough(fallthroughVariation(999)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); @@ -119,7 +119,7 @@ public void flagReturnsErrorIfFallthroughHasNegativeVariation() throws Exception .on(true) .offVariation(1) .fallthrough(fallthroughVariation(-1)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); @@ -133,7 +133,7 @@ public void flagReturnsErrorIfFallthroughHasNeitherVariationNorRollout() throws .on(true) .offVariation(1) .fallthrough(new VariationOrRollout(null, null)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); @@ -148,7 +148,7 @@ public void flagReturnsErrorIfFallthroughHasEmptyRolloutVariationList() throws E .offVariation(1) .fallthrough(new VariationOrRollout(null, new VariationOrRollout.Rollout(ImmutableList.of(), null))) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); @@ -163,12 +163,12 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); 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(fromJsonValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -179,7 +179,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); FeatureFlag f1 = new FeatureFlagBuilder("feature1") @@ -187,19 +187,19 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio .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")) + .variations(LDValue.of("nogo"), LDValue.of("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(fromJsonValue(LDValue.of("off"), 1, expectedReason), 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(LDValue.of("go"), event.value); assertEquals(f1.getVersion(), event.version.intValue()); assertEquals(f0.getKey(), event.prereqOf); } @@ -211,25 +211,25 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); FeatureFlag f1 = new FeatureFlagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(0)) - .variations(js("nogo"), js("go")) + .variations(LDValue.of("nogo"), LDValue.of("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(fromJsonValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); assertEquals(f1.getKey(), event.key); - assertEquals(js("nogo"), event.value); + assertEquals(LDValue.of("nogo"), event.value); assertEquals(f1.getVersion(), event.version.intValue()); assertEquals(f0.getKey(), event.prereqOf); } @@ -241,24 +241,24 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); FeatureFlag f1 = new FeatureFlagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) + .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(fromJsonValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), 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(LDValue.of("go"), event.value); assertEquals(f1.getVersion(), event.version.intValue()); assertEquals(f0.getKey(), event.prereqOf); } @@ -270,38 +270,38 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); FeatureFlag f1 = new FeatureFlagBuilder("feature1") .on(true) .prerequisites(Arrays.asList(new Prerequisite("feature2", 1))) .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) + .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); FeatureFlag f2 = new FeatureFlagBuilder("feature2") .on(true) .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) + .variations(LDValue.of("nogo"), LDValue.of("go")) .version(3) .build(); featureStore.upsert(FEATURES, f1); featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.fallthrough(), 0, js("fall")), result.getDetails()); + assertEquals(fromJsonValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); assertEquals(f2.getKey(), event0.key); - assertEquals(js("go"), event0.value); + assertEquals(LDValue.of("go"), event0.value); assertEquals(f2.getVersion(), event0.version.intValue()); assertEquals(f1.getKey(), event0.prereqOf); Event.FeatureRequest event1 = result.getPrerequisiteEvents().get(1); assertEquals(f1.getKey(), event1.key); - assertEquals(js("go"), event1.value); + assertEquals(LDValue.of("go"), event1.value); assertEquals(f1.getVersion(), event1.version.intValue()); assertEquals(f0.getKey(), event1.prereqOf); } @@ -313,32 +313,32 @@ public void flagMatchesUserFromTargets() throws Exception { .targets(Arrays.asList(new Target(Arrays.asList("whoever", "userkey"), 2))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.targetMatch(), 2, js("on")), result.getDetails()); + assertEquals(fromJsonValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @Test 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); + Clause clause0 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("wrongkey")), false); + Clause clause1 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(1, "ruleid1"), 2, js("on")), result.getDetails()); + assertEquals(fromJsonValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @Test public void ruleWithTooHighVariationReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); Rule rule = new Rule("ruleid", Arrays.asList(clause), 999, null); FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); @@ -350,7 +350,7 @@ public void ruleWithTooHighVariationReturnsMalformedFlagError() { @Test public void ruleWithNegativeVariationReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); Rule rule = new Rule("ruleid", Arrays.asList(clause), -1, null); FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); @@ -362,7 +362,7 @@ public void ruleWithNegativeVariationReturnsMalformedFlagError() { @Test public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); Rule rule = new Rule("ruleid", Arrays.asList(clause), null, null); FeatureFlag f = featureFlagWithRules("feature", rule); LDUser user = new LDUser.Builder("userkey").build(); @@ -374,7 +374,7 @@ public void ruleWithNoVariationOrRolloutReturnsMalformedFlagError() { @Test public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { - Clause clause = new Clause("key", Operator.in, Arrays.asList(js("userkey")), false); + Clause clause = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); Rule rule = new Rule("ruleid", Arrays.asList(clause), null, new VariationOrRollout.Rollout(ImmutableList.of(), null)); FeatureFlag f = featureFlagWithRules("feature", rule); @@ -387,38 +387,38 @@ public void ruleWithRolloutWithEmptyVariationsListReturnsMalformedFlagError() { @Test public void clauseCanMatchBuiltInAttribute() throws Exception { - Clause clause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false); + Clause clause = new Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), false); FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); + assertEquals(LDValue.of(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); + Clause clause = new Clause("legs", Operator.in, Arrays.asList(LDValue.of(4)), false); FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").custom("legs", 4).build(); - assertEquals(jbool(true), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); + assertEquals(LDValue.of(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); + Clause clause = new Clause("legs", Operator.in, Arrays.asList(LDValue.of(4)), false); FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); + assertEquals(LDValue.of(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); + Clause clause = new Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), true); FeatureFlag f = booleanFlagWithClauses("flag", clause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); + assertEquals(LDValue.of(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); } @Test @@ -438,30 +438,30 @@ public void clauseWithUnsupportedOperatorStringIsUnmarshalledWithNullOperator() @Test public void clauseWithNullOperatorDoesNotMatch() throws Exception { - Clause badClause = new Clause("name", null, Arrays.asList(js("Bob")), false); + Clause badClause = new Clause("name", null, Arrays.asList(LDValue.of("Bob")), false); FeatureFlag f = booleanFlagWithClauses("flag", badClause); LDUser user = new LDUser.Builder("key").name("Bob").build(); - assertEquals(jbool(false), f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails().getValue()); + assertEquals(LDValue.of(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); + Clause badClause = new Clause("name", null, Arrays.asList(LDValue.of("Bob")), false); Rule badRule = new Rule("rule1", Arrays.asList(badClause), 1, null); - Clause goodClause = new Clause("name", Operator.in, Arrays.asList(js("Bob")), false); + Clause goodClause = new Clause("name", Operator.in, Arrays.asList(LDValue.of("Bob")), false); Rule goodRule = new Rule("rule2", Arrays.asList(goodClause), 1, null); FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) .rules(Arrays.asList(badRule, goodRule)) .fallthrough(fallthroughVariation(0)) .offVariation(0) - .variations(jbool(false), jbool(true)) + .variations(LDValue.of(false), LDValue.of(true)) .build(); LDUser user = new LDUser.Builder("key").name("Bob").build(); - EvaluationDetail details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); - assertEquals(new EvaluationDetail<>(EvaluationReason.ruleMatch(1, "rule2"), 1, jbool(true)), details); + EvaluationDetail details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); + assertEquals(fromJsonValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); } @Test @@ -476,7 +476,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.getDetails().getValue()); + assertEquals(LDValue.of(true), result.getDetails().getValue()); } @Test @@ -485,7 +485,7 @@ 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.getDetails().getValue()); + assertEquals(LDValue.of(false), result.getDetails().getValue()); } private FeatureFlag featureFlagWithRules(String flagKey, Rule... rules) { @@ -494,12 +494,12 @@ private FeatureFlag featureFlagWithRules(String flagKey, Rule... rules) { .rules(Arrays.asList(rules)) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .build(); } private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { - Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js(segmentKey)), false); + Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of(segmentKey)), false); return booleanFlagWithClauses("flag", clause); } } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index bff704643..c7a831bb9 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -3,9 +3,11 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; +import static com.launchdarkly.client.EvaluationDetail.fromJsonValue; import static com.launchdarkly.client.TestUtil.js; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; @@ -15,7 +17,7 @@ public class FeatureFlagsStateTest { @Test public void canGetFlagValue() { - EvaluationDetail eval = new EvaluationDetail(EvaluationReason.off(), 1, js("value")); + EvaluationDetail eval = fromJsonValue(LDValue.of("value"), 1, EvaluationReason.off()); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); @@ -31,7 +33,7 @@ public void unknownFlagReturnsNullValue() { @Test public void canGetFlagReason() { - EvaluationDetail eval = new EvaluationDetail(EvaluationReason.off(), 1, js("value")); + EvaluationDetail eval = fromJsonValue(LDValue.of("value1"), 1, EvaluationReason.off()); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag, eval).build(); @@ -48,7 +50,7 @@ public void unknownFlagReturnsNullReason() { @Test public void reasonIsNullIfReasonsWereNotRecorded() { - EvaluationDetail eval = new EvaluationDetail(EvaluationReason.off(), 1, js("value")); + EvaluationDetail eval = fromJsonValue(LDValue.of("value1"), 1, EvaluationReason.off()); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); @@ -57,7 +59,7 @@ public void reasonIsNullIfReasonsWereNotRecorded() { @Test public void flagCanHaveNullValue() { - EvaluationDetail eval = new EvaluationDetail(null, 1, null); + EvaluationDetail eval = fromJsonValue(LDValue.ofNull(), 1, null); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); @@ -66,9 +68,9 @@ public void flagCanHaveNullValue() { @Test public void canConvertToValuesMap() { - EvaluationDetail eval1 = new EvaluationDetail(EvaluationReason.off(), 0, js("value1")); + EvaluationDetail eval1 = fromJsonValue(LDValue.of("value1"), 0, EvaluationReason.off()); FeatureFlag flag1 = new FeatureFlagBuilder("key1").build(); - EvaluationDetail eval2 = new EvaluationDetail(EvaluationReason.off(), 1, js("value2")); + EvaluationDetail eval2 = fromJsonValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); FeatureFlag flag2 = new FeatureFlagBuilder("key2").build(); FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -79,9 +81,9 @@ public void canConvertToValuesMap() { @Test public void canConvertToJson() { - EvaluationDetail eval1 = new EvaluationDetail(EvaluationReason.off(), 0, js("value1")); + EvaluationDetail eval1 = fromJsonValue(LDValue.of("value1"), 0, EvaluationReason.off()); FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = new EvaluationDetail(EvaluationReason.fallthrough(), 1, js("value2")); + EvaluationDetail eval2 = fromJsonValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -102,9 +104,9 @@ public void canConvertToJson() { @Test public void canConvertFromJson() { - EvaluationDetail eval1 = new EvaluationDetail(null, 0, js("value1")); + EvaluationDetail eval1 = fromJsonValue(LDValue.of("value1"), 0, EvaluationReason.off()); FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = new EvaluationDetail(null, 1, js("value2")); + EvaluationDetail eval2 = fromJsonValue(LDValue.of("value2"), 1, EvaluationReason.off()); FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index 1e286048a..a513e9d0d 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -2,7 +2,7 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -16,12 +16,13 @@ import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +@SuppressWarnings("javadoc") public class LDClientEndToEndTest { private static final Gson gson = new Gson(); private static final String sdkKey = "sdk-key"; private static final String flagKey = "flag1"; private static final FeatureFlag flag = new FeatureFlagBuilder(flagKey) - .offVariation(0).variations(new JsonPrimitive(true)) + .offVariation(0).variations(LDValue.of(true)) .build(); private static final LDUser user = new LDUser("user-key"); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index a509d076f..fe4c5f14c 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -3,7 +3,8 @@ 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.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -15,10 +16,6 @@ import static com.launchdarkly.client.TestUtil.fallthroughVariation; 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; @@ -28,6 +25,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +@SuppressWarnings("javadoc") public class LDClientEvaluationTest { private static final LDUser user = new LDUser("userkey"); private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); @@ -44,7 +42,7 @@ public class LDClientEvaluationTest { @Test public void boolVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); assertTrue(client.boolVariation("key", user, false)); } @@ -56,31 +54,31 @@ public void boolVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void boolVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); assertFalse(client.boolVariation("key", user, false)); } @Test public void intVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @Test public void intVariationReturnsFlagValueEvenIfEncodedAsDouble() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.0))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2.0))); assertEquals(new Integer(2), client.intVariation("key", user, 1)); } @Test public void intVariationFromDoubleRoundsTowardZero() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("flag1", jdouble(2.25))); - featureStore.upsert(FEATURES, flagWithValue("flag2", jdouble(2.75))); - featureStore.upsert(FEATURES, flagWithValue("flag3", jdouble(-2.25))); - featureStore.upsert(FEATURES, flagWithValue("flag4", jdouble(-2.75))); + featureStore.upsert(FEATURES, flagWithValue("flag1", LDValue.of(2.25))); + featureStore.upsert(FEATURES, flagWithValue("flag2", LDValue.of(2.75))); + featureStore.upsert(FEATURES, flagWithValue("flag3", LDValue.of(-2.25))); + featureStore.upsert(FEATURES, flagWithValue("flag4", LDValue.of(-2.75))); assertEquals(new Integer(2), client.intVariation("flag1", user, 1)); assertEquals(new Integer(2), client.intVariation("flag2", user, 1)); @@ -95,21 +93,21 @@ public void intVariationReturnsDefaultValueForUnknownFlag() throws Exception { @Test public void intVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("wrong"))); assertEquals(new Integer(1), client.intVariation("key", user, 1)); } @Test public void doubleVariationReturnsFlagValue() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jdouble(2.5d))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2.5d))); assertEquals(new Double(2.5d), client.doubleVariation("key", user, 1.0d)); } @Test public void doubleVariationReturnsFlagValueEvenIfEncodedAsInt() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jint(2))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(2))); assertEquals(new Double(2.0d), client.doubleVariation("key", user, 1.0d)); } @@ -121,14 +119,14 @@ public void doubleVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void doubleVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", js("wrong"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("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"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("b"))); assertEquals("b", client.stringVariation("key", user, "a")); } @@ -140,25 +138,40 @@ public void stringVariationReturnsDefaultValueForUnknownFlag() throws Exception @Test public void stringVariationReturnsDefaultValueForWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); assertEquals("a", client.stringVariation("key", user, "a")); } + @SuppressWarnings("deprecation") @Test - public void jsonVariationReturnsFlagValue() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); + public void deprecatedJsonVariationReturnsFlagValue() throws Exception { + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); featureStore.upsert(FEATURES, flagWithValue("key", data)); - assertEquals(data, client.jsonVariation("key", user, jint(42))); + assertEquals(data.asJsonElement(), client.jsonVariation("key", user, new JsonPrimitive(42))); } + @SuppressWarnings("deprecation") @Test - public void jsonVariationReturnsDefaultValueForUnknownFlag() throws Exception { - JsonElement defaultVal = jint(42); + public void deprecatedJsonVariationReturnsDefaultValueForUnknownFlag() throws Exception { + JsonElement defaultVal = new JsonPrimitive(42); assertEquals(defaultVal, client.jsonVariation("key", user, defaultVal)); } + + @Test + public void jsonValueVariationReturnsFlagValue() throws Exception { + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); + featureStore.upsert(FEATURES, flagWithValue("key", data)); + + assertEquals(data, client.jsonValueVariation("key", user, LDValue.of(42))); + } + + @Test + public void jsonValueVariationReturnsDefaultValueForUnknownFlag() throws Exception { + LDValue defaultVal = LDValue.of(42); + assertEquals(defaultVal, client.jsonValueVariation("key", user, defaultVal)); + } @Test public void canMatchUserBySegment() throws Exception { @@ -169,7 +182,7 @@ public void canMatchUserBySegment() throws Exception { .build(); featureStore.upsert(SEGMENTS, segment); - Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(js("segment1")), false); + Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of("segment1")), false); FeatureFlag feature = booleanFlagWithClauses("feature", clause); featureStore.upsert(FEATURES, feature); @@ -178,9 +191,10 @@ public void canMatchUserBySegment() throws Exception { @Test public void canGetDetailsForSuccessfulEvaluation() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = new EvaluationDetail<>(EvaluationReason.off(), 0, true); + EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(true, LDValue.of(true), + 0, EvaluationReason.off()); assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); } @@ -197,7 +211,8 @@ 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 expected = EvaluationDetail.fromValueWithJsonValue("default", LDValue.of("default"), + null, EvaluationReason.off()); EvaluationDetail actual = client.stringVariationDetail("key", user, "default"); assertEquals(expected, actual); assertTrue(actual.isDefaultValue()); @@ -213,30 +228,34 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { .startWaitMillis(0) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY, false); + EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(false, LDValue.of(false), null, + EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } } @Test public void appropriateErrorIfFlagDoesNotExist() throws Exception { - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND, "default"); + EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue("default", LDValue.of("default"), null, + EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); assertEquals(expectedResult, client.stringVariationDetail("key", user, "default")); } @Test public void appropriateErrorIfUserNotSpecified() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED, "default"); + EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue("default", LDValue.of("default"), null, + EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); assertEquals(expectedResult, client.stringVariationDetail("key", null, "default")); } @Test public void appropriateErrorIfValueWrongType() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.WRONG_TYPE, 3); + EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(3, LDValue.of(3), null, + EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); assertEquals(expectedResult, client.intVariationDetail("key", user, 3)); } @@ -249,7 +268,8 @@ public void appropriateErrorForUnexpectedException() throws Exception { .updateProcessorFactory(Components.nullUpdateProcessor()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, false); + EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(false, LDValue.of(false), null, + EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } } @@ -257,17 +277,17 @@ public void appropriateErrorForUnexpectedException() throws Exception { @SuppressWarnings("deprecation") @Test public void allFlagsReturnsFlagValues() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key1", js("value1"))); - featureStore.upsert(FEATURES, flagWithValue("key2", js("value2"))); + featureStore.upsert(FEATURES, flagWithValue("key1", LDValue.of("value1"))); + featureStore.upsert(FEATURES, flagWithValue("key2", LDValue.of("value2"))); Map result = client.allFlags(user); - assertEquals(ImmutableMap.of("key1", js("value1"), "key2", js("value2")), result); + assertEquals(ImmutableMap.of("key1", new JsonPrimitive("value1"), "key2", new JsonPrimitive("value2")), result); } @SuppressWarnings("deprecation") @Test public void allFlagsReturnsNullForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", js("value"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); assertNull(client.allFlags(null)); } @@ -275,7 +295,7 @@ public void allFlagsReturnsNullForNullUser() throws Exception { @SuppressWarnings("deprecation") @Test public void allFlagsReturnsNullForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", js("value"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); assertNull(client.allFlags(userWithNullKey)); } @@ -287,7 +307,7 @@ public void allFlagsStateReturnsState() throws Exception { .trackEvents(false) .on(false) .offVariation(0) - .variations(js("value1")) + .variations(LDValue.of("value1")) .build(); FeatureFlag flag2 = new FeatureFlagBuilder("key2") .version(200) @@ -295,7 +315,7 @@ public void allFlagsStateReturnsState() throws Exception { .debugEventsUntilDate(1000L) .on(true) .fallthrough(fallthroughVariation(1)) - .variations(js("off"), js("value2")) + .variations(LDValue.of("off"), LDValue.of("value2")) .build(); featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); @@ -322,9 +342,9 @@ 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(); + .variations(LDValue.of("value1")).offVariation(0).build(); FeatureFlag flag4 = new FeatureFlagBuilder("client-side-2").clientSide(true) - .variations(js("value2")).offVariation(0).build(); + .variations(LDValue.of("value2")).offVariation(0).build(); featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); featureStore.upsert(FEATURES, flag3); @@ -334,7 +354,7 @@ public void allFlagsStateCanFilterForOnlyClientSideFlags() { assertTrue(state.isValid()); Map allValues = state.toValuesMap(); - assertEquals(ImmutableMap.of("client-side-1", js("value1"), "client-side-2", js("value2")), allValues); + assertEquals(ImmutableMap.of("client-side-1", new JsonPrimitive("value1"), "client-side-2", new JsonPrimitive("value2")), allValues); } @Test @@ -344,7 +364,7 @@ public void allFlagsStateReturnsStateWithReasons() { .trackEvents(false) .on(false) .offVariation(0) - .variations(js("value1")) + .variations(LDValue.of("value1")) .build(); FeatureFlag flag2 = new FeatureFlagBuilder("key2") .version(200) @@ -352,7 +372,7 @@ public void allFlagsStateReturnsStateWithReasons() { .debugEventsUntilDate(1000L) .on(true) .fallthrough(fallthroughVariation(1)) - .variations(js("off"), js("value2")) + .variations(LDValue.of("off"), LDValue.of("value2")) .build(); featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); @@ -382,14 +402,14 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { .trackEvents(false) .on(false) .offVariation(0) - .variations(js("value1")) + .variations(LDValue.of("value1")) .build(); FeatureFlag flag2 = new FeatureFlagBuilder("key2") .version(200) .trackEvents(true) .on(true) .fallthrough(fallthroughVariation(1)) - .variations(js("off"), js("value2")) + .variations(LDValue.of("off"), LDValue.of("value2")) .build(); FeatureFlag flag3 = new FeatureFlagBuilder("key3") .version(300) @@ -397,7 +417,7 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { .debugEventsUntilDate(futureTime) // event tracking is turned on temporarily even though trackEvents is false .on(false) .offVariation(0) - .variations(js("value3")) + .variations(LDValue.of("value3")) .build(); featureStore.upsert(FEATURES, flag1); featureStore.upsert(FEATURES, flag2); @@ -424,7 +444,7 @@ public void allFlagsStateCanOmitDetailsForUntrackedFlags() { @Test public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", js("value"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); FeatureFlagsState state = client.allFlagsState(null); assertFalse(state.isValid()); @@ -433,7 +453,7 @@ public void allFlagsStateReturnsEmptyStateForNullUser() throws Exception { @Test public void allFlagsStateReturnsEmptyStateForNullUserKey() throws Exception { - featureStore.upsert(FEATURES, flagWithValue("key", js("value"))); + featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of("value"))); FeatureFlagsState state = client.allFlagsState(userWithNullKey); assertFalse(state.isValid()); diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 7004a88cf..10b48cc89 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -1,9 +1,8 @@ package com.launchdarkly.client; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.EvaluationReason.ErrorKind; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -11,10 +10,6 @@ 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; -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; @@ -25,6 +20,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +@SuppressWarnings("javadoc") public class LDClientEventTest { private static final LDUser user = new LDUser("userkey"); private static final LDUser userWithNullKey = new LDUser.Builder((String)null).build(); @@ -71,14 +67,13 @@ public void trackSendsEventWithoutData() throws Exception { Event.Custom ce = (Event.Custom)e; assertEquals(user.getKey(), ce.user.getKey()); assertEquals("eventkey", ce.key); - assertNull(ce.data); + assertEquals(LDValue.ofNull(), ce.data); } @Test public void trackSendsEventWithData() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); - client.track("eventkey", user, data); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); + client.trackData("eventkey", user, data); assertEquals(1, eventSink.events.size()); Event e = eventSink.events.get(0); @@ -91,8 +86,7 @@ public void trackSendsEventWithData() throws Exception { @Test public void trackSendsEventWithDataAndMetricValue() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); double metricValue = 1.5; client.track("eventkey", user, data, metricValue); @@ -120,184 +114,208 @@ public void trackWithUserWithNoKeyDoesNotSendEvent() { @Test public void boolVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", jbool(true)); + FeatureFlag flag = flagWithValue("key", LDValue.of(true)); featureStore.upsert(FEATURES, flag); client.boolVariation("key", user, false); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jbool(true), jbool(false), null, null); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of(true), LDValue.of(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, null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of(false), null, null); } @Test public void boolVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", jbool(true)); + FeatureFlag flag = flagWithValue("key", LDValue.of(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()); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of(true), LDValue.of(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, + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of(false), null, EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } @Test public void intVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", jint(2)); + FeatureFlag flag = flagWithValue("key", LDValue.of(2)); featureStore.upsert(FEATURES, flag); client.intVariation("key", user, 1); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jint(2), jint(1), null, null); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of(2), LDValue.of(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, null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of(1), null, null); } @Test public void intVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", jint(2)); + FeatureFlag flag = flagWithValue("key", LDValue.of(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()); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of(2), LDValue.of(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, + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of(1), null, EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } @Test public void doubleVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", jdouble(2.5d)); + FeatureFlag flag = flagWithValue("key", LDValue.of(2.5d)); featureStore.upsert(FEATURES, flag); client.doubleVariation("key", user, 1.0d); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, jdouble(2.5d), jdouble(1.0d), null, null); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of(2.5d), LDValue.of(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, null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of(1.0), null, null); } @Test public void doubleVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", jdouble(2.5d)); + FeatureFlag flag = flagWithValue("key", LDValue.of(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()); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of(2.5d), LDValue.of(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, + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of(1.0), null, EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } @Test public void stringVariationSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", js("b")); + FeatureFlag flag = flagWithValue("key", LDValue.of("b")); featureStore.upsert(FEATURES, flag); client.stringVariation("key", user, "a"); assertEquals(1, eventSink.events.size()); - checkFeatureEvent(eventSink.events.get(0), flag, js("b"), js("a"), null, null); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of("b"), LDValue.of("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, null); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of("a"), null, null); } @Test public void stringVariationDetailSendsEvent() throws Exception { - FeatureFlag flag = flagWithValue("key", js("b")); + FeatureFlag flag = flagWithValue("key", LDValue.of("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()); + checkFeatureEvent(eventSink.events.get(0), flag, LDValue.of("b"), LDValue.of("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, + checkUnknownFeatureEvent(eventSink.events.get(0), "key", LDValue.of("a"), null, EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); } + @SuppressWarnings("deprecation") @Test public void jsonVariationSendsEvent() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); - JsonElement defaultVal = new JsonPrimitive(42); + LDValue defaultVal = LDValue.of(42); - client.jsonVariation("key", user, defaultVal); + client.jsonVariation("key", user, new JsonPrimitive(defaultVal.intValue())); assertEquals(1, eventSink.events.size()); checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, null); } + @SuppressWarnings("deprecation") @Test public void jsonVariationSendsEventForUnknownFlag() throws Exception { - JsonElement defaultVal = new JsonPrimitive(42); + LDValue defaultVal = LDValue.of(42); - client.jsonVariation("key", user, defaultVal); + client.jsonVariation("key", user, new JsonPrimitive(defaultVal.intValue())); assertEquals(1, eventSink.events.size()); checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, null); } + @SuppressWarnings("deprecation") @Test public void jsonVariationDetailSendsEvent() throws Exception { - JsonObject data = new JsonObject(); - data.addProperty("thing", "stuff"); + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); FeatureFlag flag = flagWithValue("key", data); featureStore.upsert(FEATURES, flag); - JsonElement defaultVal = new JsonPrimitive(42); + LDValue defaultVal = LDValue.of(42); - client.jsonVariationDetail("key", user, defaultVal); + client.jsonVariationDetail("key", user, new JsonPrimitive(defaultVal.intValue())); assertEquals(1, eventSink.events.size()); checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, EvaluationReason.off()); } + @SuppressWarnings("deprecation") @Test public void jsonVariationDetailSendsEventForUnknownFlag() throws Exception { - JsonElement defaultVal = new JsonPrimitive(42); + LDValue defaultVal = LDValue.of(42); - client.jsonVariationDetail("key", user, defaultVal); + client.jsonVariationDetail("key", user, new JsonPrimitive(defaultVal.intValue())); + assertEquals(1, eventSink.events.size()); + checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, + EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); + } + + @Test + public void jsonValueVariationDetailSendsEvent() throws Exception { + LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); + FeatureFlag flag = flagWithValue("key", data); + featureStore.upsert(FEATURES, flag); + LDValue defaultVal = LDValue.of(42); + + client.jsonValueVariationDetail("key", user, defaultVal); + assertEquals(1, eventSink.events.size()); + checkFeatureEvent(eventSink.events.get(0), flag, data, defaultVal, null, EvaluationReason.off()); + } + + @Test + public void jsonValueVariationDetailSendsEventForUnknownFlag() throws Exception { + LDValue defaultVal = LDValue.of(42); + + client.jsonValueVariationDetail("key", user, defaultVal); assertEquals(1, eventSink.events.size()); checkUnknownFeatureEvent(eventSink.events.get(0), "key", defaultVal, null, EvaluationReason.error(ErrorKind.FLAG_NOT_FOUND)); @@ -311,7 +329,7 @@ public void eventTrackingAndReasonCanBeForcedForRule() throws Exception { .on(true) .rules(Arrays.asList(rule)) .offVariation(0) - .variations(js("off"), js("on")) + .variations(LDValue.of("off"), LDValue.of("on")) .build(); featureStore.upsert(FEATURES, flag); @@ -336,7 +354,7 @@ public void eventTrackingAndReasonAreNotForcedIfFlagIsNotSetForMatchingRule() th .on(true) .rules(Arrays.asList(rule0, rule1)) .offVariation(0) - .variations(js("off"), js("on")) + .variations(LDValue.of("off"), LDValue.of("on")) .build(); featureStore.upsert(FEATURES, flag); @@ -355,7 +373,7 @@ public void eventTrackingAndReasonCanBeForcedForFallthrough() throws Exception { FeatureFlag flag = new FeatureFlagBuilder("flag") .on(true) .fallthrough(new VariationOrRollout(0, null)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); featureStore.upsert(FEATURES, flag); @@ -376,7 +394,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfFlagIsNotSet() thr FeatureFlag flag = new FeatureFlagBuilder("flag") .on(true) .fallthrough(new VariationOrRollout(0, null)) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(false) .build(); featureStore.upsert(FEATURES, flag); @@ -395,7 +413,7 @@ public void eventTrackingAndReasonAreNotForcedForFallthroughIfReasonIsNotFallthr .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")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .trackEventsFallthrough(true) .build(); featureStore.upsert(FEATURES, flag); @@ -415,13 +433,13 @@ public void eventIsSentForExistingPrererequisiteFlag() throws Exception { .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); FeatureFlag f1 = new FeatureFlagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) + .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); featureStore.upsert(FEATURES, f0); @@ -430,8 +448,8 @@ 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", null); - checkFeatureEvent(eventSink.events.get(1), f0, js("fall"), js("default"), null, null); + checkFeatureEvent(eventSink.events.get(0), f1, LDValue.of("go"), LDValue.ofNull(), "feature0", null); + checkFeatureEvent(eventSink.events.get(1), f0, LDValue.of("fall"), LDValue.of("default"), null, null); } @Test @@ -441,13 +459,13 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); FeatureFlag f1 = new FeatureFlagBuilder("feature1") .on(true) .fallthrough(fallthroughVariation(1)) - .variations(js("nogo"), js("go")) + .variations(LDValue.of("nogo"), LDValue.of("go")) .version(2) .build(); featureStore.upsert(FEATURES, f0); @@ -456,8 +474,8 @@ public void eventIsSentWithReasonForExistingPrererequisiteFlag() throws Exceptio 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()); + checkFeatureEvent(eventSink.events.get(0), f1, LDValue.of("go"), LDValue.ofNull(), "feature0", EvaluationReason.fallthrough()); + checkFeatureEvent(eventSink.events.get(1), f0, LDValue.of("fall"), LDValue.of("default"), null, EvaluationReason.fallthrough()); } @Test @@ -467,7 +485,7 @@ public void eventIsNotSentForUnknownPrererequisiteFlag() throws Exception { .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); featureStore.upsert(FEATURES, f0); @@ -475,7 +493,7 @@ 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, null); + checkFeatureEvent(eventSink.events.get(0), f0, LDValue.of("off"), LDValue.of("default"), null, null); } @Test @@ -485,7 +503,7 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) .fallthrough(fallthroughVariation(0)) .offVariation(1) - .variations(js("fall"), js("off"), js("on")) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) .version(1) .build(); featureStore.upsert(FEATURES, f0); @@ -493,11 +511,11 @@ public void failureReasonIsGivenForUnknownPrererequisiteFlagIfDetailsWereRequest client.stringVariationDetail("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, LDValue.of("off"), LDValue.of("default"), null, EvaluationReason.prerequisiteFailed("feature1")); } - private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, JsonElement defaultVal, + private void checkFeatureEvent(Event e, FeatureFlag flag, LDValue value, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; @@ -512,7 +530,7 @@ private void checkFeatureEvent(Event e, FeatureFlag flag, JsonElement value, Jso assertEquals(flag.getDebugEventsUntilDate(), fe.debugEventsUntilDate); } - private void checkUnknownFeatureEvent(Event e, String key, JsonElement defaultVal, String prereqOf, + private void checkUnknownFeatureEvent(Event e, String key, LDValue defaultVal, String prereqOf, EvaluationReason reason) { assertEquals(Event.FeatureRequest.class, e.getClass()); Event.FeatureRequest fe = (Event.FeatureRequest)e; diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java index f77030875..76ee3611a 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.launchdarkly.client.value.LDValue; + import org.junit.Test; import java.io.IOException; @@ -11,6 +13,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +@SuppressWarnings("javadoc") public class LDClientLddModeTest { @Test public void lddModeClientHasNullUpdateProcessor() throws IOException { @@ -49,7 +52,7 @@ public void lddModeClientGetsFlagFromFeatureStore() throws IOException { .useLdd(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - FeatureFlag flag = flagWithValue("key", TestUtil.jbool(true)); + FeatureFlag flag = flagWithValue("key", LDValue.of(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 472e22b30..cff7ed994 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -2,6 +2,7 @@ import com.google.common.collect.ImmutableMap; import com.google.gson.JsonElement; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -16,6 +17,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +@SuppressWarnings("javadoc") public class LDClientOfflineTest { private static final LDUser user = new LDUser("user"); @@ -66,7 +68,7 @@ public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { .offline(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - testFeatureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { Map allFlags = client.allFlags(user); assertEquals(ImmutableMap.of("key", jbool(true)), allFlags); @@ -80,7 +82,7 @@ public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { .offline(true) .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); - testFeatureStore.upsert(FEATURES, flagWithValue("key", jbool(true))); + testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { FeatureFlagsState state = client.allFlagsState(user); assertTrue(state.isValid()); diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 158977fad..8229cb1bb 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -5,6 +5,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import com.launchdarkly.client.value.LDValue; import org.easymock.Capture; import org.easymock.EasyMock; @@ -22,7 +23,6 @@ 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.TestUtil.updateProcessorWithData; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -41,6 +41,7 @@ /** * See also LDClientEvaluationTest, etc. This file contains mostly tests for the startup logic. */ +@SuppressWarnings("javadoc") public class LDClientTest extends EasyMockSupport { private UpdateProcessor updateProcessor; private EventProcessor eventProcessor; @@ -180,7 +181,7 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); + testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @@ -213,7 +214,7 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); + testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertFalse(client.isFlagKnown("key")); verifyAll(); } @@ -230,7 +231,7 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); + testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertTrue(client.isFlagKnown("key")); verifyAll(); } @@ -248,7 +249,7 @@ public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Excep client = createMockClient(config); - testFeatureStore.upsert(FEATURES, flagWithValue("key", jint(1))); + testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(1))); assertEquals(new Integer(1), client.intVariation("key", new LDUser("user"), 0)); verifyAll(); diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index ccbfe8c95..cca734229 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -7,8 +7,8 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; -import com.google.gson.JsonPrimitive; import com.google.gson.reflect.TypeToken; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -26,6 +26,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +@SuppressWarnings("javadoc") public class LDUserTest { private static final Gson defaultGson = new Gson(); @@ -56,123 +57,123 @@ public void canSetKey() { @Test public void canSetSecondary() { LDUser user = new LDUser.Builder("key").secondary("s").build(); - assertEquals("s", user.getSecondary().getAsString()); + assertEquals("s", user.getSecondary().stringValue()); } @Test public void canSetPrivateSecondary() { LDUser user = new LDUser.Builder("key").privateSecondary("s").build(); - assertEquals("s", user.getSecondary().getAsString()); + assertEquals("s", user.getSecondary().stringValue()); assertEquals(ImmutableSet.of("secondary"), user.privateAttributeNames); } @Test public void canSetIp() { LDUser user = new LDUser.Builder("key").ip("i").build(); - assertEquals("i", user.getIp().getAsString()); + assertEquals("i", user.getIp().stringValue()); } @Test public void canSetPrivateIp() { LDUser user = new LDUser.Builder("key").privateIp("i").build(); - assertEquals("i", user.getIp().getAsString()); + assertEquals("i", user.getIp().stringValue()); assertEquals(ImmutableSet.of("ip"), user.privateAttributeNames); } @Test public void canSetEmail() { LDUser user = new LDUser.Builder("key").email("e").build(); - assertEquals("e", user.getEmail().getAsString()); + assertEquals("e", user.getEmail().stringValue()); } @Test public void canSetPrivateEmail() { LDUser user = new LDUser.Builder("key").privateEmail("e").build(); - assertEquals("e", user.getEmail().getAsString()); + assertEquals("e", user.getEmail().stringValue()); assertEquals(ImmutableSet.of("email"), user.privateAttributeNames); } @Test public void canSetName() { LDUser user = new LDUser.Builder("key").name("n").build(); - assertEquals("n", user.getName().getAsString()); + assertEquals("n", user.getName().stringValue()); } @Test public void canSetPrivateName() { LDUser user = new LDUser.Builder("key").privateName("n").build(); - assertEquals("n", user.getName().getAsString()); + assertEquals("n", user.getName().stringValue()); assertEquals(ImmutableSet.of("name"), user.privateAttributeNames); } @Test public void canSetAvatar() { LDUser user = new LDUser.Builder("key").avatar("a").build(); - assertEquals("a", user.getAvatar().getAsString()); + assertEquals("a", user.getAvatar().stringValue()); } @Test public void canSetPrivateAvatar() { LDUser user = new LDUser.Builder("key").privateAvatar("a").build(); - assertEquals("a", user.getAvatar().getAsString()); + assertEquals("a", user.getAvatar().stringValue()); assertEquals(ImmutableSet.of("avatar"), user.privateAttributeNames); } @Test public void canSetFirstName() { LDUser user = new LDUser.Builder("key").firstName("f").build(); - assertEquals("f", user.getFirstName().getAsString()); + assertEquals("f", user.getFirstName().stringValue()); } @Test public void canSetPrivateFirstName() { LDUser user = new LDUser.Builder("key").privateFirstName("f").build(); - assertEquals("f", user.getFirstName().getAsString()); + assertEquals("f", user.getFirstName().stringValue()); assertEquals(ImmutableSet.of("firstName"), user.privateAttributeNames); } @Test public void canSetLastName() { LDUser user = new LDUser.Builder("key").lastName("l").build(); - assertEquals("l", user.getLastName().getAsString()); + assertEquals("l", user.getLastName().stringValue()); } @Test public void canSetPrivateLastName() { LDUser user = new LDUser.Builder("key").privateLastName("l").build(); - assertEquals("l", user.getLastName().getAsString()); + assertEquals("l", user.getLastName().stringValue()); assertEquals(ImmutableSet.of("lastName"), user.privateAttributeNames); } @Test public void canSetAnonymous() { LDUser user = new LDUser.Builder("key").anonymous(true).build(); - assertEquals(true, user.getAnonymous().getAsBoolean()); + assertEquals(true, user.getAnonymous().booleanValue()); } @Test public void canSetCountry() { LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); - assertEquals("US", user.getCountry().getAsString()); + assertEquals("US", user.getCountry().stringValue()); } @Test public void canSetCountryAsString() { LDUser user = new LDUser.Builder("key").country("US").build(); - assertEquals("US", user.getCountry().getAsString()); + assertEquals("US", user.getCountry().stringValue()); } @Test public void canSetCountryAs3CharacterString() { LDUser user = new LDUser.Builder("key").country("USA").build(); - assertEquals("US", user.getCountry().getAsString()); + assertEquals("US", user.getCountry().stringValue()); } @Test 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(); - assertEquals("US", user.getCountry().getAsString()); + assertEquals("US", user.getCountry().stringValue()); } @Test @@ -185,76 +186,93 @@ public void ambiguousCountryNameSetsCountryWithPartialMatch() { @Test public void partialUniqueMatchSetsCountry() { LDUser user = new LDUser.Builder("key").country("United States Minor").build(); - assertEquals("UM", user.getCountry().getAsString()); + assertEquals("UM", user.getCountry().stringValue()); } @Test public void invalidCountryNameDoesNotSetCountry() { LDUser user = new LDUser.Builder("key").country("East Jibip").build(); - assertNull(user.getCountry()); + assertEquals(LDValue.ofNull(), user.getCountry()); } @Test public void canSetPrivateCountry() { LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); - assertEquals("US", user.getCountry().getAsString()); + assertEquals("US", user.getCountry().stringValue()); assertEquals(ImmutableSet.of("country"), user.privateAttributeNames); } @Test public void canSetCustomString() { LDUser user = new LDUser.Builder("key").custom("thing", "value").build(); - assertEquals("value", user.getCustom("thing").getAsString()); + assertEquals("value", user.getCustom("thing").stringValue()); } @Test public void canSetPrivateCustomString() { LDUser user = new LDUser.Builder("key").privateCustom("thing", "value").build(); - assertEquals("value", user.getCustom("thing").getAsString()); + assertEquals("value", user.getCustom("thing").stringValue()); assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); } @Test public void canSetCustomInt() { LDUser user = new LDUser.Builder("key").custom("thing", 1).build(); - assertEquals(1, user.getCustom("thing").getAsInt()); + assertEquals(1, user.getCustom("thing").intValue()); } @Test public void canSetPrivateCustomInt() { LDUser user = new LDUser.Builder("key").privateCustom("thing", 1).build(); - assertEquals(1, user.getCustom("thing").getAsInt()); + assertEquals(1, user.getCustom("thing").intValue()); 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()); + assertEquals(true, user.getCustom("thing").booleanValue()); } @Test public void canSetPrivateCustomBoolean() { LDUser user = new LDUser.Builder("key").privateCustom("thing", true).build(); - assertEquals(true, user.getCustom("thing").getAsBoolean()); + assertEquals(true, user.getCustom("thing").booleanValue()); assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); } - + @Test public void canSetCustomJsonValue() { - JsonObject value = new JsonObject(); + LDValue value = LDValue.buildObject().put("1", LDValue.of("x")).build(); LDUser user = new LDUser.Builder("key").custom("thing", value).build(); assertEquals(value, user.getCustom("thing")); } @Test public void canSetPrivateCustomJsonValue() { - JsonObject value = new JsonObject(); + LDValue value = LDValue.buildObject().put("1", LDValue.of("x")).build(); LDUser user = new LDUser.Builder("key").privateCustom("thing", value).build(); assertEquals(value, user.getCustom("thing")); assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); } + @SuppressWarnings("deprecation") + @Test + public void canSetDeprecatedCustomJsonValue() { + JsonObject value = new JsonObject(); + LDUser user = new LDUser.Builder("key").custom("thing", value).build(); + assertEquals(value, user.getCustom("thing").asJsonElement()); + } + + @SuppressWarnings("deprecation") + @Test + public void canSetPrivateDeprecatedCustomJsonValue() { + JsonObject value = new JsonObject(); + LDUser user = new LDUser.Builder("key").privateCustom("thing", value).build(); + assertEquals(value, user.getCustom("thing").asJsonElement()); + assertEquals(ImmutableSet.of("thing"), user.privateAttributeNames); + } + @Test public void testAllPropertiesInDefaultEncoding() { for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { @@ -373,7 +391,7 @@ public void getValueGetsBuiltInAttribute() { LDUser user = new LDUser.Builder("key") .name("Jane") .build(); - assertEquals(new JsonPrimitive("Jane"), user.getValueForEvaluation("name")); + assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); } @Test @@ -381,7 +399,7 @@ public void getValueGetsCustomAttribute() { LDUser user = new LDUser.Builder("key") .custom("height", 5) .build(); - assertEquals(new JsonPrimitive(5), user.getValueForEvaluation("height")); + assertEquals(LDValue.of(5), user.getValueForEvaluation("height")); } @Test @@ -390,7 +408,7 @@ public void getValueGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { .name("Jane") .custom("name", "Joan") .build(); - assertEquals(new JsonPrimitive("Jane"), user.getValueForEvaluation("name")); + assertEquals(LDValue.of("Jane"), user.getValueForEvaluation("name")); } @Test @@ -398,7 +416,7 @@ public void getValueReturnsNullIfNotFound() { LDUser user = new LDUser.Builder("key") .name("Jane") .build(); - assertNull(user.getValueForEvaluation("height")); + assertEquals(LDValue.ofNull(), user.getValueForEvaluation("height")); } @Test diff --git a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java b/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java index c2f5acfde..e4e8ca61a 100644 --- a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java +++ b/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java @@ -1,30 +1,31 @@ package com.launchdarkly.client; -import static org.junit.Assert.assertEquals; - -import java.util.Arrays; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; -import com.google.gson.JsonPrimitive; +import java.util.Arrays; + +import static org.junit.Assert.assertEquals; +@SuppressWarnings("javadoc") @RunWith(Parameterized.class) public class OperatorParameterizedTest { - private static JsonPrimitive dateStr1 = new JsonPrimitive("2017-12-06T00:00:00.000-07:00"); - private static JsonPrimitive dateStr2 = new JsonPrimitive("2017-12-06T00:01:01.000-07:00"); - private static JsonPrimitive dateMs1 = new JsonPrimitive(10000000); - private static JsonPrimitive dateMs2 = new JsonPrimitive(10000001); - private static JsonPrimitive invalidDate = new JsonPrimitive("hey what's this?"); - private static JsonPrimitive invalidVer = new JsonPrimitive("xbad%ver"); + private static LDValue dateStr1 = LDValue.of("2017-12-06T00:00:00.000-07:00"); + private static LDValue dateStr2 = LDValue.of("2017-12-06T00:01:01.000-07:00"); + private static LDValue dateMs1 = LDValue.of(10000000); + private static LDValue dateMs2 = LDValue.of(10000001); + private static LDValue invalidDate = LDValue.of("hey what's this?"); + private static LDValue invalidVer = LDValue.of("xbad%ver"); private final Operator op; - private final JsonPrimitive aValue; - private final JsonPrimitive bValue; + private final LDValue aValue; + private final LDValue bValue; private final boolean shouldBe; - public OperatorParameterizedTest(Operator op, JsonPrimitive aValue, JsonPrimitive bValue, boolean shouldBe) { + public OperatorParameterizedTest(Operator op, LDValue aValue, LDValue bValue, boolean shouldBe) { this.op = op; this.aValue = aValue; this.bValue = bValue; @@ -35,50 +36,50 @@ public OperatorParameterizedTest(Operator op, JsonPrimitive aValue, JsonPrimitiv public static Iterable data() { return Arrays.asList(new Object[][] { // numeric comparisons - { Operator.in, ji(99), ji(99), true }, - { Operator.in, jd(99.0001), jd(99.0001), true }, - { Operator.in, ji(99), jd(99.0001), false }, - { Operator.in, jd(99.0001), ji(99), false }, - { Operator.lessThan, ji(99), jd(99.0001), true }, - { Operator.lessThan, jd(99.0001), ji(99), false }, - { Operator.lessThan, ji(99), ji(99), false }, - { Operator.lessThanOrEqual, ji(99), jd(99.0001), true }, - { Operator.lessThanOrEqual, jd(99.0001), ji(99), false }, - { Operator.lessThanOrEqual, ji(99), ji(99), true }, - { Operator.greaterThan, jd(99.0001), ji(99), true }, - { Operator.greaterThan, ji(99), jd(99.0001), false }, - { Operator.greaterThan, ji(99), ji(99), false }, - { Operator.greaterThanOrEqual, jd(99.0001), ji(99), true }, - { Operator.greaterThanOrEqual, ji(99), jd(99.0001), false }, - { Operator.greaterThanOrEqual, ji(99), ji(99), true }, + { Operator.in, LDValue.of(99), LDValue.of(99), true }, + { Operator.in, LDValue.of(99.0001), LDValue.of(99.0001), true }, + { Operator.in, LDValue.of(99), LDValue.of(99.0001), false }, + { Operator.in, LDValue.of(99.0001), LDValue.of(99), false }, + { Operator.lessThan, LDValue.of(99), LDValue.of(99.0001), true }, + { Operator.lessThan, LDValue.of(99.0001), LDValue.of(99), false }, + { Operator.lessThan, LDValue.of(99), LDValue.of(99), false }, + { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99.0001), true }, + { Operator.lessThanOrEqual, LDValue.of(99.0001), LDValue.of(99), false }, + { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of(99), true }, + { Operator.greaterThan, LDValue.of(99.0001), LDValue.of(99), true }, + { Operator.greaterThan, LDValue.of(99), LDValue.of(99.0001), false }, + { Operator.greaterThan, LDValue.of(99), LDValue.of(99), false }, + { Operator.greaterThanOrEqual, LDValue.of(99.0001), LDValue.of(99), true }, + { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99.0001), false }, + { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of(99), true }, // string comparisons - { Operator.in, js("x"), js("x"), true }, - { Operator.in, js("x"), js("xyz"), false }, - { Operator.startsWith, js("xyz"), js("x"), true }, - { Operator.startsWith, js("x"), js("xyz"), false }, - { Operator.endsWith, js("xyz"), js("z"), true }, - { Operator.endsWith, js("z"), js("xyz"), false }, - { Operator.contains, js("xyz"), js("y"), true }, - { Operator.contains, js("y"), js("xyz"), false }, + { Operator.in, LDValue.of("x"), LDValue.of("x"), true }, + { Operator.in, LDValue.of("x"), LDValue.of("xyz"), false }, + { Operator.startsWith, LDValue.of("xyz"), LDValue.of("x"), true }, + { Operator.startsWith, LDValue.of("x"), LDValue.of("xyz"), false }, + { Operator.endsWith, LDValue.of("xyz"), LDValue.of("z"), true }, + { Operator.endsWith, LDValue.of("z"), LDValue.of("xyz"), false }, + { Operator.contains, LDValue.of("xyz"), LDValue.of("y"), true }, + { Operator.contains, LDValue.of("y"), LDValue.of("xyz"), false }, // mixed strings and numbers - { Operator.in, js("99"), ji(99), false }, - { Operator.in, ji(99), js("99"), false }, - { Operator.contains, js("99"), ji(99), false }, - { Operator.startsWith, js("99"), ji(99), false }, - { Operator.endsWith, js("99"), ji(99), false }, - { Operator.lessThanOrEqual, js("99"), ji(99), false }, - { Operator.lessThanOrEqual, ji(99), js("99"), false }, - { Operator.greaterThanOrEqual, js("99"), ji(99), false }, - { Operator.greaterThanOrEqual, ji(99), js("99"), false }, + { Operator.in, LDValue.of("99"), LDValue.of(99), false }, + { Operator.in, LDValue.of(99), LDValue.of("99"), false }, + { Operator.contains, LDValue.of("99"), LDValue.of(99), false }, + { Operator.startsWith, LDValue.of("99"), LDValue.of(99), false }, + { Operator.endsWith, LDValue.of("99"), LDValue.of(99), false }, + { Operator.lessThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { Operator.lessThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, + { Operator.greaterThanOrEqual, LDValue.of("99"), LDValue.of(99), false }, + { Operator.greaterThanOrEqual, LDValue.of(99), LDValue.of("99"), false }, // regex - { Operator.matches, js("hello world"), js("hello.*rld"), true }, - { Operator.matches, js("hello world"), js("hello.*orl"), true }, - { Operator.matches, js("hello world"), js("l+"), true }, - { Operator.matches, js("hello world"), js("(world|planet)"), true }, - { Operator.matches, js("hello world"), js("aloha"), false }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*rld"), true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("hello.*orl"), true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("l+"), true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("(world|planet)"), true }, + { Operator.matches, LDValue.of("hello world"), LDValue.of("aloha"), false }, // dates { Operator.before, dateStr1, dateStr2, true }, @@ -97,24 +98,24 @@ public static Iterable data() { { Operator.after, dateStr1, invalidDate, false }, // semver - { Operator.semVerEqual, js("2.0.1"), js("2.0.1"), true }, - { Operator.semVerEqual, js("2.0"), js("2.0.0"), true }, - { Operator.semVerEqual, js("2"), js("2.0.0"), true }, - { Operator.semVerEqual, js("2-rc1"), js("2.0.0-rc1"), true }, - { Operator.semVerEqual, js("2+build2"), js("2.0.0+build2"), true }, - { Operator.semVerLessThan, js("2.0.0"), js("2.0.1"), true }, - { Operator.semVerLessThan, js("2.0"), js("2.0.1"), true }, - { Operator.semVerLessThan, js("2.0.1"), js("2.0.0"), false }, - { Operator.semVerLessThan, js("2.0.1"), js("2.0"), false }, - { Operator.semVerLessThan, js("2.0.0-rc"), js("2.0.0"), true }, - { Operator.semVerLessThan, js("2.0.0-rc"), js("2.0.0-rc.beta"), true }, - { Operator.semVerGreaterThan, js("2.0.1"), js("2.0.0"), true }, - { Operator.semVerGreaterThan, js("2.0.1"), js("2.0"), true }, - { Operator.semVerGreaterThan, js("2.0.0"), js("2.0.1"), false }, - { Operator.semVerGreaterThan, js("2.0"), js("2.0.1"), false }, - { Operator.semVerGreaterThan, js("2.0.0-rc.1"), js("2.0.0-rc.0"), true }, - { Operator.semVerLessThan, js("2.0.1"), invalidVer, false }, - { Operator.semVerGreaterThan, js("2.0.1"), invalidVer, false } + { Operator.semVerEqual, LDValue.of("2.0.1"), LDValue.of("2.0.1"), true }, + { Operator.semVerEqual, LDValue.of("2.0"), LDValue.of("2.0.0"), true }, + { Operator.semVerEqual, LDValue.of("2"), LDValue.of("2.0.0"), true }, + { Operator.semVerEqual, LDValue.of("2-rc1"), LDValue.of("2.0.0-rc1"), true }, + { Operator.semVerEqual, LDValue.of("2+build2"), LDValue.of("2.0.0+build2"), true }, + { Operator.semVerLessThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), true }, + { Operator.semVerLessThan, LDValue.of("2.0"), LDValue.of("2.0.1"), true }, + { Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), false }, + { Operator.semVerLessThan, LDValue.of("2.0.1"), LDValue.of("2.0"), false }, + { Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0"), true }, + { Operator.semVerLessThan, LDValue.of("2.0.0-rc"), LDValue.of("2.0.0-rc.beta"), true }, + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0.0"), true }, + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), LDValue.of("2.0"), true }, + { Operator.semVerGreaterThan, LDValue.of("2.0.0"), LDValue.of("2.0.1"), false }, + { Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), false }, + { Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), true }, + { Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, false }, + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false } }); } @@ -122,16 +123,4 @@ public static Iterable data() { public void parameterizedTestComparison() { assertEquals(shouldBe, op.apply(aValue, bValue)); } - - private static JsonPrimitive js(String s) { - return new JsonPrimitive(s); - } - - private static JsonPrimitive ji(int n) { - return new JsonPrimitive(n); - } - - private static JsonPrimitive jd(double d) { - return new JsonPrimitive(d); - } } diff --git a/src/test/java/com/launchdarkly/client/OperatorTest.java b/src/test/java/com/launchdarkly/client/OperatorTest.java index 424f5e64b..4b55d4c0b 100644 --- a/src/test/java/com/launchdarkly/client/OperatorTest.java +++ b/src/test/java/com/launchdarkly/client/OperatorTest.java @@ -1,18 +1,19 @@ package com.launchdarkly.client; -import static org.junit.Assert.assertFalse; - -import java.util.regex.PatternSyntaxException; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; -import com.google.gson.JsonPrimitive; +import java.util.regex.PatternSyntaxException; + +import static org.junit.Assert.assertFalse; // Any special-case tests that can't be handled by OperatorParameterizedTest. +@SuppressWarnings("javadoc") public class OperatorTest { // This is probably not desired behavior, but it is the current behavior @Test(expected = PatternSyntaxException.class) public void testInvalidRegexThrowsException() { - assertFalse(Operator.matches.apply(new JsonPrimitive("hello world"), new JsonPrimitive("***not a regex"))); + assertFalse(Operator.matches.apply(LDValue.of("hello world"), LDValue.of("***not a regex"))); } } diff --git a/src/test/java/com/launchdarkly/client/SegmentTest.java b/src/test/java/com/launchdarkly/client/SegmentTest.java index 0bc45f7a5..ccd9d5225 100644 --- a/src/test/java/com/launchdarkly/client/SegmentTest.java +++ b/src/test/java/com/launchdarkly/client/SegmentTest.java @@ -1,14 +1,15 @@ package com.launchdarkly.client; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import java.util.Arrays; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; -import com.google.gson.JsonPrimitive; +import java.util.Arrays; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +@SuppressWarnings("javadoc") public class SegmentTest { private int maxWeight = 100000; @@ -55,7 +56,7 @@ public void matchingRuleWithFullRollout() { Clause clause = new Clause( "email", Operator.in, - Arrays.asList(new JsonPrimitive("test@example.com")), + Arrays.asList(LDValue.of("test@example.com")), false); SegmentRule rule = new SegmentRule( Arrays.asList(clause), @@ -75,7 +76,7 @@ public void matchingRuleWithZeroRollout() { Clause clause = new Clause( "email", Operator.in, - Arrays.asList(new JsonPrimitive("test@example.com")), + Arrays.asList(LDValue.of("test@example.com")), false); SegmentRule rule = new SegmentRule(Arrays.asList(clause), 0, @@ -94,12 +95,12 @@ public void matchingRuleWithMultipleClauses() { Clause clause1 = new Clause( "email", Operator.in, - Arrays.asList(new JsonPrimitive("test@example.com")), + Arrays.asList(LDValue.of("test@example.com")), false); Clause clause2 = new Clause( "name", Operator.in, - Arrays.asList(new JsonPrimitive("bob")), + Arrays.asList(LDValue.of("bob")), false); SegmentRule rule = new SegmentRule( Arrays.asList(clause1, clause2), @@ -119,12 +120,12 @@ public void nonMatchingRuleWithMultipleClauses() { Clause clause1 = new Clause( "email", Operator.in, - Arrays.asList(new JsonPrimitive("test@example.com")), + Arrays.asList(LDValue.of("test@example.com")), false); Clause clause2 = new Clause( "name", Operator.in, - Arrays.asList(new JsonPrimitive("bill")), + Arrays.asList(LDValue.of("bill")), false); SegmentRule rule = new SegmentRule( Arrays.asList(clause1, clause2), diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index a8cac0ed0..b576fe92f 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -5,6 +5,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; import org.hamcrest.Description; import org.hamcrest.Matcher; @@ -21,6 +22,7 @@ import static org.hamcrest.Matchers.equalTo; +@SuppressWarnings("javadoc") public class TestUtil { public static FeatureStoreFactory specificFeatureStore(final FeatureStore store) { @@ -164,11 +166,11 @@ public static FeatureFlag booleanFlagWithClauses(String key, Clause... clauses) .rules(Arrays.asList(rule)) .fallthrough(fallthroughVariation(0)) .offVariation(0) - .variations(jbool(false), jbool(true)) + .variations(LDValue.of(false), LDValue.of(true)) .build(); } - public static FeatureFlag flagWithValue(String key, JsonElement value) { + public static FeatureFlag flagWithValue(String key, LDValue value) { return new FeatureFlagBuilder(key) .on(false) .offVariation(0) @@ -181,7 +183,7 @@ public static Clause makeClauseToMatchUser(LDUser user) { } public static Clause makeClauseToNotMatchUser(LDUser user) { - return new Clause("key", Operator.in, Arrays.asList(js("not-" + user.getKeyAsString())), false); + return new Clause("key", Operator.in, Arrays.asList(LDValue.of("not-" + user.getKeyAsString())), false); } public static class DataBuilder { @@ -205,14 +207,19 @@ public DataBuilder add(VersionedDataKind kind, VersionedData... items) { } } - public static EvaluationDetail simpleEvaluation(int variation, JsonElement value) { - return new EvaluationDetail<>(EvaluationReason.fallthrough(), variation, value); + public static EvaluationDetail simpleEvaluation(int variation, LDValue value) { + return EvaluationDetail.fromJsonValue(value, variation, EvaluationReason.fallthrough()); } public static Matcher hasJsonProperty(final String name, JsonElement value) { return hasJsonProperty(name, equalTo(value)); } + @SuppressWarnings("deprecation") + public static Matcher hasJsonProperty(final String name, LDValue value) { + return hasJsonProperty(name, equalTo(value.asUnsafeJsonElement())); + } + public static Matcher hasJsonProperty(final String name, String value) { return hasJsonProperty(name, new JsonPrimitive(value)); } diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java index 3be37ba6a..f478b7607 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -1,18 +1,20 @@ package com.launchdarkly.client; -import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.value.LDValue; + import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.junit.Assert; import org.junit.Test; +@SuppressWarnings("javadoc") public class UtilTest { @Test public void testDateTimeConversionWithTimeZone() { String validRFC3339String = "2016-04-16T17:09:12.759-07:00"; String expected = "2016-04-17T00:09:12.759Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); Assert.assertEquals(expected, actual.toString()); } @@ -20,7 +22,7 @@ public void testDateTimeConversionWithTimeZone() { public void testDateTimeConversionWithUtc() { String validRFC3339String = "1970-01-01T00:00:01.001Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); Assert.assertEquals(validRFC3339String, actual.toString()); } @@ -29,7 +31,7 @@ public void testDateTimeConversionWithNoTimeZone() { String validRFC3339String = "2016-04-16T17:09:12.759"; String expected = "2016-04-16T17:09:12.759Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); Assert.assertEquals(expected, actual.toString()); } @@ -38,7 +40,7 @@ public void testDateTimeConversionTimestampWithNoMillis() { String validRFC3339String = "2016-04-16T17:09:12"; String expected = "2016-04-16T17:09:12.000Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(validRFC3339String)); + DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(validRFC3339String)); Assert.assertEquals(expected, actual.toString()); } @@ -46,7 +48,7 @@ public void testDateTimeConversionTimestampWithNoMillis() { public void testDateTimeConversionAsUnixMillis() { long unixMillis = 1000; String expected = "1970-01-01T00:00:01.000Z"; - DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(unixMillis)); + DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(unixMillis)); Assert.assertEquals(expected, actual.withZone(DateTimeZone.UTC).toString()); } @@ -54,22 +56,22 @@ public void testDateTimeConversionAsUnixMillis() { public void testDateTimeConversionCompare() { long aMillis = 1001; String bStamp = "1970-01-01T00:00:01.001Z"; - DateTime a = Util.jsonPrimitiveToDateTime(new JsonPrimitive(aMillis)); - DateTime b = Util.jsonPrimitiveToDateTime(new JsonPrimitive(bStamp)); + DateTime a = Util.jsonPrimitiveToDateTime(LDValue.of(aMillis)); + DateTime b = Util.jsonPrimitiveToDateTime(LDValue.of(bStamp)); Assert.assertTrue(a.getMillis() == b.getMillis()); } @Test public void testDateTimeConversionAsUnixMillisBeforeEpoch() { long unixMillis = -1000; - DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(unixMillis)); + DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(unixMillis)); Assert.assertEquals(unixMillis, actual.getMillis()); } @Test public void testDateTimeConversionInvalidString() { String invalidTimestamp = "May 3, 1980"; - DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(invalidTimestamp)); + DateTime actual = Util.jsonPrimitiveToDateTime(LDValue.of(invalidTimestamp)); Assert.assertNull(actual); } } diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java new file mode 100644 index 000000000..96057d613 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -0,0 +1,362 @@ +package com.launchdarkly.client.value; + +import com.google.common.collect.ImmutableList; +import com.google.gson.Gson; +import com.google.gson.JsonPrimitive; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +@SuppressWarnings({"deprecation", "javadoc"}) +public class LDValueTest { + private static final Gson gson = new Gson(); + + private static final int someInt = 3; + private static final float someFloat = 3.25f; + private static final double someDouble = 3.25d; + private static final String someString = "hi"; + + private static final LDValue aTrueBoolValue = LDValue.of(true); + private static final LDValue anIntValue = LDValue.of(someInt); + private static final LDValue aFloatValue = LDValue.of(someFloat); + private static final LDValue aDoubleValue = LDValue.of(someDouble); + private static final LDValue aStringValue = LDValue.of(someString); + private static final LDValue aNumericLookingStringValue = LDValue.of("3"); + private static final LDValue anArrayValue = LDValue.buildArray().add(LDValue.of(3)).build(); + private static final LDValue anObjectValue = LDValue.buildObject().put("1", LDValue.of("x")).build(); + + private static final LDValue aTrueBoolValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(true)); + private static final LDValue anIntValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someInt)); + private static final LDValue aFloatValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someFloat)); + private static final LDValue aDoubleValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someDouble)); + private static final LDValue aStringValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someString)); + private static final LDValue anArrayValueFromJsonElement = LDValue.fromJsonElement(anArrayValue.asJsonElement()); + private static final LDValue anObjectValueFromJsonElement = LDValue.fromJsonElement(anObjectValue.asJsonElement()); + + @Test + public void defaultValueJsonElementsAreReused() { + assertSame(LDValue.of(true).asJsonElement(), LDValue.of(true).asJsonElement()); + assertSame(LDValue.of(false).asJsonElement(), LDValue.of(false).asJsonElement()); + assertSame(LDValue.of((int)0).asJsonElement(), LDValue.of((int)0).asJsonElement()); + assertSame(LDValue.of((float)0).asJsonElement(), LDValue.of((float)0).asJsonElement()); + assertSame(LDValue.of((double)0).asJsonElement(), LDValue.of((double)0).asJsonElement()); + assertSame(LDValue.of("").asJsonElement(), LDValue.of("").asJsonElement()); + } + + @Test + public void canGetValueAsBoolean() { + assertEquals(LDValueType.BOOLEAN, aTrueBoolValue.getType()); + assertTrue(aTrueBoolValue.booleanValue()); + assertEquals(LDValueType.BOOLEAN, aTrueBoolValueFromJsonElement.getType()); + assertTrue(aTrueBoolValueFromJsonElement.booleanValue()); + } + + @Test + public void nonBooleanValueAsBooleanIsFalse() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aStringValue, + aStringValueFromJsonElement, + anIntValue, + anIntValueFromJsonElement, + aFloatValue, + aFloatValueFromJsonElement, + aDoubleValue, + aDoubleValueFromJsonElement, + anArrayValue, + anArrayValueFromJsonElement, + anObjectValue, + anObjectValueFromJsonElement + }; + for (LDValue value: values) { + assertNotEquals(value.toString(), LDValueType.BOOLEAN, value.getType()); + assertFalse(value.toString(), value.booleanValue()); + } + } + + @Test + public void canGetValueAsString() { + assertEquals(LDValueType.STRING, aStringValue.getType()); + assertEquals(someString, aStringValue.stringValue()); + assertEquals(LDValueType.STRING, aStringValueFromJsonElement.getType()); + assertEquals(someString, aStringValueFromJsonElement.stringValue()); + } + + @Test + public void nonStringValueAsStringIsNull() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + aTrueBoolValueFromJsonElement, + anIntValue, + anIntValueFromJsonElement, + aFloatValue, + aFloatValueFromJsonElement, + aDoubleValue, + aDoubleValueFromJsonElement, + anArrayValue, + anArrayValueFromJsonElement, + anObjectValue, + anObjectValueFromJsonElement + }; + for (LDValue value: values) { + assertNotEquals(value.toString(), LDValueType.STRING, value.getType()); + assertNull(value.toString(), value.stringValue()); + } + } + + @Test + public void nullStringConstructorGivesNullInstance() { + assertEquals(LDValue.ofNull(), LDValue.of((String)null)); + } + + @Test + public void canGetIntegerValueOfAnyNumericType() { + LDValue[] values = new LDValue[] { + LDValue.of(3), + LDValue.of(3.0f), + LDValue.of(3.25f), + LDValue.of(3.75f), + LDValue.of(3.0d), + LDValue.of(3.25d), + LDValue.of(3.75d) + }; + for (LDValue value: values) { + assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); + assertEquals(value.toString(), 3, value.intValue()); + } + } + + @Test + public void canGetFloatValueOfAnyNumericType() { + LDValue[] values = new LDValue[] { + LDValue.of(3), + LDValue.of(3.0f), + LDValue.of(3.0d), + }; + for (LDValue value: values) { + assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); + assertEquals(value.toString(), 3.0f, value.floatValue(), 0); + } + } + + @Test + public void canGetDoubleValueOfAnyNumericType() { + LDValue[] values = new LDValue[] { + LDValue.of(3), + LDValue.of(3.0f), + LDValue.of(3.0d), + }; + for (LDValue value: values) { + assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); + assertEquals(value.toString(), 3.0d, value.doubleValue(), 0); + } + } + + @Test + public void nonNumericValueAsNumberIsZero() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + aTrueBoolValueFromJsonElement, + aStringValue, + aStringValueFromJsonElement, + aNumericLookingStringValue, + anArrayValue, + anArrayValueFromJsonElement, + anObjectValue, + anObjectValueFromJsonElement + }; + for (LDValue value: values) { + assertNotEquals(value.toString(), LDValueType.NUMBER, value.getType()); + assertEquals(value.toString(), 0, value.intValue()); + assertEquals(value.toString(), 0f, value.floatValue(), 0); + assertEquals(value.toString(), 0d, value.doubleValue(), 0); + } + } + + @Test + public void canGetSizeOfArrayOrObject() { + assertEquals(1, anArrayValue.size()); + assertEquals(1, anArrayValueFromJsonElement.size()); + } + + @Test + public void arrayCanGetItemByIndex() { + assertEquals(LDValueType.ARRAY, anArrayValue.getType()); + assertEquals(LDValueType.ARRAY, anArrayValueFromJsonElement.getType()); + assertEquals(LDValue.of(3), anArrayValue.get(0)); + assertEquals(LDValue.of(3), anArrayValueFromJsonElement.get(0)); + assertEquals(LDValue.ofNull(), anArrayValue.get(-1)); + assertEquals(LDValue.ofNull(), anArrayValue.get(1)); + assertEquals(LDValue.ofNull(), anArrayValueFromJsonElement.get(-1)); + assertEquals(LDValue.ofNull(), anArrayValueFromJsonElement.get(1)); + } + + @Test + public void arrayCanBeEnumerated() { + LDValue a = LDValue.of("a"); + LDValue b = LDValue.of("b"); + List values = new ArrayList<>(); + for (LDValue v: LDValue.buildArray().add(a).add(b).build().values()) { + values.add(v); + } + assertEquals(ImmutableList.of(a, b), values); + } + + @Test + public void nonArrayValuesBehaveLikeEmptyArray() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + aTrueBoolValueFromJsonElement, + anIntValue, + aFloatValue, + aDoubleValue, + aStringValue, + aNumericLookingStringValue, + }; + for (LDValue value: values) { + assertEquals(value.toString(), 0, value.size()); + assertEquals(value.toString(), LDValue.of(null), value.get(-1)); + assertEquals(value.toString(), LDValue.of(null), value.get(0)); + for (@SuppressWarnings("unused") LDValue v: value.values()) { + fail(value.toString()); + } + } + } + + @Test + public void canGetSizeOfObject() { + assertEquals(1, anObjectValue.size()); + assertEquals(1, anObjectValueFromJsonElement.size()); + } + + @Test + public void objectCanGetValueByName() { + assertEquals(LDValueType.OBJECT, anObjectValue.getType()); + assertEquals(LDValueType.OBJECT, anObjectValueFromJsonElement.getType()); + assertEquals(LDValue.of("x"), anObjectValue.get("1")); + assertEquals(LDValue.of("x"), anObjectValueFromJsonElement.get("1")); + assertEquals(LDValue.ofNull(), anObjectValue.get(null)); + assertEquals(LDValue.ofNull(), anObjectValueFromJsonElement.get(null)); + assertEquals(LDValue.ofNull(), anObjectValue.get("2")); + assertEquals(LDValue.ofNull(), anObjectValueFromJsonElement.get("2")); + } + + @Test + public void objectKeysCanBeEnumerated() { + List keys = new ArrayList<>(); + for (String key: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().keys()) { + keys.add(key); + } + keys.sort(null); + assertEquals(ImmutableList.of("1", "2"), keys); + } + + @Test + public void objectValuesCanBeEnumerated() { + List values = new ArrayList<>(); + for (LDValue value: LDValue.buildObject().put("1", LDValue.of("x")).put("2", LDValue.of("y")).build().values()) { + values.add(value.stringValue()); + } + values.sort(null); + assertEquals(ImmutableList.of("x", "y"), values); + } + + @Test + public void nonObjectValuesBehaveLikeEmptyObject() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + aTrueBoolValueFromJsonElement, + anIntValue, + aFloatValue, + aDoubleValue, + aStringValue, + aNumericLookingStringValue, + }; + for (LDValue value: values) { + assertEquals(value.toString(), LDValue.of(null), value.get(null)); + assertEquals(value.toString(), LDValue.of(null), value.get("1")); + for (@SuppressWarnings("unused") String key: value.keys()) { + fail(value.toString()); + } + } + } + + @Test + public void samePrimitivesWithOrWithoutJsonElementAreEqual() { + assertEquals(aTrueBoolValue, aTrueBoolValueFromJsonElement); + assertEquals(anIntValue, anIntValueFromJsonElement); + assertEquals(aFloatValue, aFloatValueFromJsonElement); + assertEquals(aStringValue, aStringValueFromJsonElement); + assertEquals(anArrayValue, anArrayValueFromJsonElement); + assertEquals(anObjectValue, anObjectValueFromJsonElement); + } + + @Test + public void testToJsonString() { + assertEquals("null", LDValue.ofNull().toJsonString()); + assertEquals("true", aTrueBoolValue.toJsonString()); + assertEquals("true", aTrueBoolValueFromJsonElement.toJsonString()); + assertEquals("false", LDValue.of(false).toJsonString()); + assertEquals(String.valueOf(someInt), anIntValue.toJsonString()); + assertEquals(String.valueOf(someInt), anIntValueFromJsonElement.toJsonString()); + assertEquals(String.valueOf(someFloat), aFloatValue.toJsonString()); + assertEquals(String.valueOf(someFloat), aFloatValueFromJsonElement.toJsonString()); + assertEquals(String.valueOf(someDouble), aDoubleValue.toJsonString()); + assertEquals(String.valueOf(someDouble), aDoubleValueFromJsonElement.toJsonString()); + assertEquals("\"hi\"", aStringValue.toJsonString()); + assertEquals("\"hi\"", aStringValueFromJsonElement.toJsonString()); + assertEquals("[3]", anArrayValue.toJsonString()); + assertEquals("[3.0]", anArrayValueFromJsonElement.toJsonString()); + assertEquals("{\"1\":\"x\"}", anObjectValue.toJsonString()); + assertEquals("{\"1\":\"x\"}", anObjectValueFromJsonElement.toJsonString()); + } + + @Test + public void testDefaultGsonSerialization() { + LDValue[] values = new LDValue[] { + LDValue.ofNull(), + aTrueBoolValue, + aTrueBoolValueFromJsonElement, + anIntValue, + anIntValueFromJsonElement, + aFloatValue, + aFloatValueFromJsonElement, + aDoubleValue, + aDoubleValueFromJsonElement, + aStringValue, + aStringValueFromJsonElement, + anArrayValue, + anArrayValueFromJsonElement, + anObjectValue, + anObjectValueFromJsonElement + }; + for (LDValue value: values) { + assertEquals(value.toString(), value.toJsonString(), gson.toJson(value)); + assertEquals(value.toString(), value, LDValue.normalize(gson.fromJson(value.toJsonString(), LDValue.class))); + } + } + + @Test + public void valueToJsonElement() { + assertNull(LDValue.ofNull().asJsonElement()); + assertEquals(new JsonPrimitive(true), aTrueBoolValue.asJsonElement()); + assertEquals(new JsonPrimitive(someInt), anIntValue.asJsonElement()); + assertEquals(new JsonPrimitive(someFloat), aFloatValue.asJsonElement()); + assertEquals(new JsonPrimitive(someDouble), aDoubleValue.asJsonElement()); + assertEquals(new JsonPrimitive(someString), aStringValue.asJsonElement()); + } +} From 2ee0b8bdaf3b874ac98d98e01199870b6f89aa7f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 27 Sep 2019 19:16:44 -0700 Subject: [PATCH 178/327] add type converters --- .../launchdarkly/client/value/LDValue.java | 216 +++++++++++++++++- .../client/value/LDValueTest.java | 40 ++++ 2 files changed, 252 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index 62bb5f3b5..5786a5d16 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -1,6 +1,8 @@ package com.launchdarkly.client.value; +import com.google.common.base.Function; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.google.gson.annotations.JsonAdapter; @@ -9,6 +11,7 @@ import com.launchdarkly.client.LDUser; import java.io.IOException; +import java.util.Map; /** * An immutable instance of any data type that is allowed in JSON. @@ -102,9 +105,12 @@ public static LDValue of(String value) { /** * Starts building an array value. - *

    +   * 
    
        *     LDValue arrayOfInts = LDValue.buildArray().add(LDValue.int(1), LDValue.int(2)).build():
    -   * 
    + *
    + * If the values are all of the same type, you may also use {@link LDValue.Converter#arrayFrom(Iterable)} + * or {@link LDValue.Converter#arrayOf(Object...)}. + * * @return an {@link ArrayBuilder} */ public static ArrayBuilder buildArray() { @@ -113,9 +119,11 @@ public static ArrayBuilder buildArray() { /** * Starts building an object value. - *
    +   * 
    
        *     LDValue objectVal = LDValue.buildObject().put("key", LDValue.int(1)).build():
    -   * 
    + *
    + * If the values are all of the same type, you may also use {@link LDValue.Converter#objectFrom(Map)}. + * * @return an {@link ObjectBuilder} */ public static ObjectBuilder buildObject() { @@ -265,6 +273,30 @@ public Iterable values() { return ImmutableList.of(); } + /** + * Enumerates the values in an array or object, converting them to a specific type. Returns an empty + * iterable for all other types. + *

    + * This is an efficient method because it does not copy values to a new list, but returns a view + * into the existing array. + *

    + * Example: + *

    
    +   *     LDValue anArrayOfInts = LDValue.Convert.Integer.arrayOf(1, 2, 3);
    +   *     for (int i: anArrayOfInts.valuesAs(LDValue.Convert.Integer)) { println(i); }
    +   * 
    + * + * @param converter the {@link Converter} for the specified type + * @return an iterable of values of the specified type + */ + public Iterable valuesAs(final Converter converter) { + return Iterables.transform(values(), new Function() { + public T apply(LDValue value) { + return converter.toType(value); + } + }); + } + /** * Returns an array element by index. Returns {@link #ofNull()} if this is not an array or if the * index is out of range (will never throw an exception). @@ -404,4 +436,180 @@ public int hashCode() { default: return 0; } } + + /** + * Defines a conversion between {@link LDValue} and some other type. + *

    + * Besides converting individual values, this provides factory methods like {@link #arrayOf} + * which transform a collection of the specified type to the corresponding {@link LDValue} + * complex type. + * + * @param the type to convert from/to + * @since 4.8.0 + */ + public static abstract class Converter { + /** + * Converts a value of the specified type to an {@link LDValue}. + *

    + * This method should never throw an exception; if for some reason the value is invalid, + * it should return {@link LDValue#ofNull()}. + * + * @param value a value of this type + * @return an {@link LDValue} + */ + public abstract LDValue fromType(T value); + + /** + * Converts an {@link LDValue} to a value of the specified type. + *

    + * This method should never throw an exception; if the conversion cannot be done, it should + * return the default value of the given type (zero for numbers, null for nullable types). + * + * @param value an {@link LDValue} + * @return a value of this type + */ + public abstract T toType(LDValue value); + + /** + * Initializes an {@link LDValue} as an array, from a sequence of this type. + *

    + * Values are copied, so subsequent changes to the source values do not affect the array. + *

    + * Example: + *

    
    +     *     List listOfInts = ImmutableList.builder().add(1).add(2).add(3).build();
    +     *     LDValue arrayValue = LDValue.Convert.Integer.arrayFrom(listOfInts);
    +     * 
    + * + * @param values a sequence of elements of the specified type + * @return a value representing a JSON array, or {@link LDValue#ofNull()} if the parameter was null + * @see LDValue#buildArray() + */ + public LDValue arrayFrom(Iterable values) { + ArrayBuilder ab = LDValue.buildArray(); + for (T value: values) { + ab.add(fromType(value)); + } + return ab.build(); + } + + /** + * Initializes an {@link LDValue} as an array, from a sequence of this type. + *

    + * Values are copied, so subsequent changes to the source values do not affect the array. + *

    + * Example: + *

    
    +     *     LDValue arrayValue = LDValue.Convert.Integer.arrayOf(1, 2, 3);
    +     * 
    + * + * @param values a sequence of elements of the specified type + * @return a value representing a JSON array, or {@link LDValue#ofNull()} if the parameter was null + * @see LDValue#buildArray() + */ + @SuppressWarnings("unchecked") + public LDValue arrayOf(T... values) { + ArrayBuilder ab = LDValue.buildArray(); + for (T value: values) { + ab.add(fromType(value)); + } + return ab.build(); + } + + /** + * Initializes an {@link LDValue} as an object, from a map containing this type. + *

    + * Values are copied, so subsequent changes to the source map do not affect the array. + *

    + * Example: + *

    
    +     *     Map mapOfInts = ImmutableMap.builder().put("a", 1).build();
    +     *     LDValue objectValue = LDValue.Convert.Integer.objectFrom(mapOfInts);
    +     * 
    + * + * @param map a map with string keys and values of the specified type + * @return a value representing a JSON object, or {@link LDValue#ofNull()} if the parameter was null + * @see LDValue#buildObject() + */ + public LDValue objectFrom(Map map) { + ObjectBuilder ob = LDValue.buildObject(); + for (String key: map.keySet()) { + ob.put(key, fromType(map.get(key))); + } + return ob.build(); + } + } + + /** + * Predefined instances of {@link LDValue.Converter} for commonly used types. + *

    + * These are mostly useful for methods that convert {@link LDValue} to or from a collection of + * some type, such as {@link LDValue.Converter#arrayOf(Object...)} and + * {@link LDValue#valuesAs(Converter)}. + * + * @since 4.8.0 + */ + public static abstract class Convert { + private Convert() {} + + /** + * A {@link LDValue.Converter} for booleans. + */ + public static final Converter Boolean = new Converter() { + public LDValue fromType(java.lang.Boolean value) { + return value == null ? LDValue.ofNull() : LDValue.of(value.booleanValue()); + } + public java.lang.Boolean toType(LDValue value) { + return java.lang.Boolean.valueOf(value.booleanValue()); + } + }; + + /** + * A {@link LDValue.Converter} for integers. + */ + public static final Converter Integer = new Converter() { + public LDValue fromType(java.lang.Integer value) { + return value == null ? LDValue.ofNull() : LDValue.of(value.intValue()); + } + public java.lang.Integer toType(LDValue value) { + return java.lang.Integer.valueOf(value.intValue()); + } + }; + + /** + * A {@link LDValue.Converter} for floats. + */ + public static final Converter Float = new Converter() { + public LDValue fromType(java.lang.Float value) { + return value == null ? LDValue.ofNull() : LDValue.of(value.floatValue()); + } + public java.lang.Float toType(LDValue value) { + return java.lang.Float.valueOf(value.floatValue()); + } + }; + + /** + * A {@link LDValue.Converter} for doubles. + */ + public static final Converter Double = new Converter() { + public LDValue fromType(java.lang.Double value) { + return value == null ? LDValue.ofNull() : LDValue.of(value.doubleValue()); + } + public java.lang.Double toType(LDValue value) { + return java.lang.Double.valueOf(value.doubleValue()); + } + }; + + /** + * A {@link LDValue.Converter} for strings. + */ + public static final Converter String = new Converter() { + public LDValue fromType(java.lang.String value) { + return LDValue.of(value); + } + public java.lang.String toType(LDValue value) { + return value.stringValue(); + } + }; + } } diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index 96057d613..61b4a3ace 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.client.value; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; import com.google.gson.JsonPrimitive; @@ -359,4 +360,43 @@ public void valueToJsonElement() { assertEquals(new JsonPrimitive(someDouble), aDoubleValue.asJsonElement()); assertEquals(new JsonPrimitive(someString), aStringValue.asJsonElement()); } + + @Test + public void testTypeConversions() { + testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, LDValue.of(true), LDValue.of(false)); + testTypeConversion(LDValue.Convert.Integer, new Integer[] { 1, 2 }, LDValue.of(1), LDValue.of(2)); + testTypeConversion(LDValue.Convert.Float, new Float[] { 1.5f, 2.5f }, LDValue.of(1.5f), LDValue.of(2.5f)); + testTypeConversion(LDValue.Convert.Double, new Double[] { 1.5d, 2.5d }, LDValue.of(1.5d), LDValue.of(2.5d)); + testTypeConversion(LDValue.Convert.String, new String[] { "a", "b" }, LDValue.of("a"), LDValue.of("b")); + } + + private void testTypeConversion(LDValue.Converter converter, T[] values, LDValue... ldValues) { + ArrayBuilder ab = LDValue.buildArray(); + for (LDValue v: ldValues) { + ab.add(v); + } + LDValue arrayValue = ab.build(); + assertEquals(arrayValue, converter.arrayOf(values)); + ImmutableList.Builder lb = ImmutableList.builder(); + for (T v: values) { + lb.add(v); + } + ImmutableList list = lb.build(); + assertEquals(arrayValue, converter.arrayFrom(list)); + assertEquals(list, ImmutableList.copyOf(arrayValue.valuesAs(converter))); + + ObjectBuilder ob = LDValue.buildObject(); + int i = 0; + for (LDValue v: ldValues) { + ob.put(String.valueOf(++i), v); + } + LDValue objectValue = ob.build(); + ImmutableMap.Builder mb = ImmutableMap.builder(); + i = 0; + for (T v: values) { + mb.put(String.valueOf(++i), v); + } + ImmutableMap map = mb.build(); + assertEquals(objectValue, converter.objectFrom(map)); + } } From ca124b455dcd1001d70f89f0a50b7956f9a1d5e1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 27 Sep 2019 19:24:34 -0700 Subject: [PATCH 179/327] add convenience method --- .../java/com/launchdarkly/client/Clause.java | 2 +- .../java/com/launchdarkly/client/Operator.java | 16 ++++++---------- src/main/java/com/launchdarkly/client/Util.java | 3 +-- .../com/launchdarkly/client/value/LDValue.java | 11 ++++++++++- .../client/value/LDValueJsonElement.java | 5 +++++ .../launchdarkly/client/value/LDValueString.java | 5 +++++ 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java index 1cda00c33..3b8278fbc 100644 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -58,7 +58,7 @@ boolean matchesUser(FeatureStore store, LDUser user) { // and possibly negate if (op == Operator.segmentMatch) { for (LDValue j: values) { - if (j.getType() == LDValueType.STRING) { + if (j.isString()) { Segment segment = store.get(SEGMENTS, j.stringValue()); if (segment != null) { if (segment.matchesUser(user)) { diff --git a/src/main/java/com/launchdarkly/client/Operator.java b/src/main/java/com/launchdarkly/client/Operator.java index ff8e61ad1..e87c92090 100644 --- a/src/main/java/com/launchdarkly/client/Operator.java +++ b/src/main/java/com/launchdarkly/client/Operator.java @@ -1,10 +1,9 @@ package com.launchdarkly.client; -import java.util.regex.Pattern; - import com.google.gson.JsonPrimitive; import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; + +import java.util.regex.Pattern; /** * Operator value that can be applied to {@link JsonPrimitive} objects. Incompatible types or other errors @@ -28,27 +27,24 @@ public boolean apply(LDValue uValue, LDValue cValue) { endsWith { @Override public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && - uValue.stringValue().endsWith(cValue.stringValue()); + return uValue.isString() && cValue.isString() && uValue.stringValue().endsWith(cValue.stringValue()); } }, startsWith { @Override public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && - uValue.stringValue().startsWith(cValue.stringValue()); + return uValue.isString() && cValue.isString() && uValue.stringValue().startsWith(cValue.stringValue()); } }, matches { public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && + return uValue.isString() && cValue.isString() && Pattern.compile(cValue.stringValue()).matcher(uValue.stringValue()).find(); } }, contains { public boolean apply(LDValue uValue, LDValue cValue) { - return uValue.getType() == LDValueType.STRING && cValue.getType() == LDValueType.STRING && - uValue.stringValue().contains(cValue.stringValue()); + return uValue.isString() && cValue.isString() && uValue.stringValue().contains(cValue.stringValue()); } }, lessThan { diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 8de1d5593..f07c4a4e3 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -2,7 +2,6 @@ import com.google.gson.JsonPrimitive; import com.launchdarkly.client.value.LDValue; -import com.launchdarkly.client.value.LDValueType; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; @@ -18,7 +17,7 @@ class Util { static DateTime jsonPrimitiveToDateTime(LDValue maybeDate) { if (maybeDate.isNumber()) { return new DateTime((long)maybeDate.doubleValue()); - } else if (maybeDate.getType() == LDValueType.STRING) { + } else if (maybeDate.isString()) { try { return new DateTime(maybeDate.stringValue(), DateTimeZone.UTC); } catch (Throwable t) { diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index 5786a5d16..c8756eb49 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -236,7 +236,16 @@ public float floatValue() { public double doubleValue() { return 0; } - + + /** + * Tests whether this value is a string. + * + * @return {@code true} if this is a string value + */ + public boolean isString() { + return false; + } + /** * Returns this value as a {@code String} if it is a string. Returns {@code null} for all non-string values. * diff --git a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java index 75d4141f0..75a5bc120 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java @@ -93,6 +93,11 @@ public double doubleValue() { return type == LDValueType.NUMBER ? value.getAsDouble() : 0; } + @Override + public boolean isString() { + return type == LDValueType.STRING; + } + @Override public String stringValue() { return type == LDValueType.STRING ? value.getAsString() : null; diff --git a/src/main/java/com/launchdarkly/client/value/LDValueString.java b/src/main/java/com/launchdarkly/client/value/LDValueString.java index e752ab888..b2ad2c789 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueString.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueString.java @@ -24,6 +24,11 @@ public LDValueType getType() { return LDValueType.STRING; } + @Override + public boolean isString() { + return true; + } + @Override public String stringValue() { return value; From af8a4a00723a21b7d829a4cfe3f4d11bf64191c3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 2 Oct 2019 18:14:47 -0700 Subject: [PATCH 180/327] support long integers --- .../launchdarkly/client/value/LDValue.java | 46 +++++++++++++++++++ .../client/value/LDValueJsonElement.java | 5 ++ .../client/value/LDValueNumber.java | 5 ++ .../client/value/LDValueTest.java | 38 +++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index c8756eb49..3cf00f514 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -72,6 +72,22 @@ public static LDValue of(boolean value) { public static LDValue of(int value) { return LDValueNumber.fromDouble(value); } + + /** + * Returns an instance for a numeric value. + *

    + * Note that the LaunchDarkly service, and most of the SDKs, represent numeric values internally + * in 64-bit floating-point, which has slightly less precision than a signed 64-bit {@code long}; + * therefore, the full range of {@code long} values cannot be accurately represented. If you need + * to set a user attribute to a numeric value with more significant digits than will fit in a + * {@code double}, it is best to encode it as a string. + * + * @param value a long integer numeric value + * @return an LDValue containing that value + */ + public static LDValue of(long value) { + return LDValueNumber.fromDouble(value); + } /** * Returns an instance for a numeric value. @@ -218,6 +234,18 @@ public boolean isInt() { public int intValue() { return 0; } + + /** + * Returns this value as a {@code long} if it is numeric. Returns zero for all non-numeric values. + *

    + * If the value is a number but not an integer, it will be rounded toward zero (truncated). + * This is consistent with Java casting behavior, and with most other LaunchDarkly SDKs. + * + * @return a {@code long} value + */ + public long longValue() { + return 0; + } /** * Returns this value as a {@code float} if it is numeric. Returns zero for all non-numeric values. @@ -584,6 +612,24 @@ public java.lang.Integer toType(LDValue value) { return java.lang.Integer.valueOf(value.intValue()); } }; + + /** + * A {@link LDValue.Converter} for long integers. + *

    + * Note that the LaunchDarkly service, and most of the SDKs, represent numeric values internally + * in 64-bit floating-point, which has slightly less precision than a signed 64-bit {@code long}; + * therefore, the full range of {@code long} values cannot be accurately represented. If you need + * to set a user attribute to a numeric value with more significant digits than will fit in a + * {@code double}, it is best to encode it as a string. + */ + public static final Converter Long = new Converter() { + public LDValue fromType(java.lang.Long value) { + return value == null ? LDValue.ofNull() : LDValue.of(value.longValue()); + } + public java.lang.Long toType(LDValue value) { + return java.lang.Long.valueOf(value.longValue()); + } + }; /** * A {@link LDValue.Converter} for floats. diff --git a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java index 75a5bc120..586c0fcaf 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java @@ -82,6 +82,11 @@ public boolean isInt() { public int intValue() { return type == LDValueType.NUMBER ? (int)value.getAsFloat() : 0; // don't rely on their rounding behavior } + + @Override + public long longValue() { + return type == LDValueType.NUMBER ? (long)value.getAsDouble() : 0; // don't rely on their rounding behavior + } @Override public float floatValue() { diff --git a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java b/src/main/java/com/launchdarkly/client/value/LDValueNumber.java index 3b5c81295..6a601c3f0 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueNumber.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueNumber.java @@ -38,6 +38,11 @@ public boolean isInt() { public int intValue() { return (int)value; } + + @Override + public long longValue() { + return (long)value; + } @Override public float floatValue() { diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index 61b4a3ace..a02a94bc9 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -23,12 +23,14 @@ public class LDValueTest { private static final Gson gson = new Gson(); private static final int someInt = 3; + private static final long someLong = 3; private static final float someFloat = 3.25f; private static final double someDouble = 3.25d; private static final String someString = "hi"; private static final LDValue aTrueBoolValue = LDValue.of(true); private static final LDValue anIntValue = LDValue.of(someInt); + private static final LDValue aLongValue = LDValue.of(someLong); private static final LDValue aFloatValue = LDValue.of(someFloat); private static final LDValue aDoubleValue = LDValue.of(someDouble); private static final LDValue aStringValue = LDValue.of(someString); @@ -38,6 +40,7 @@ public class LDValueTest { private static final LDValue aTrueBoolValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(true)); private static final LDValue anIntValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someInt)); + private static final LDValue aLongValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someLong)); private static final LDValue aFloatValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someFloat)); private static final LDValue aDoubleValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someDouble)); private static final LDValue aStringValueFromJsonElement = LDValue.fromJsonElement(new JsonPrimitive(someString)); @@ -46,9 +49,11 @@ public class LDValueTest { @Test public void defaultValueJsonElementsAreReused() { + assertSame(LDValue.ofNull(), LDValue.ofNull()); assertSame(LDValue.of(true).asJsonElement(), LDValue.of(true).asJsonElement()); assertSame(LDValue.of(false).asJsonElement(), LDValue.of(false).asJsonElement()); assertSame(LDValue.of((int)0).asJsonElement(), LDValue.of((int)0).asJsonElement()); + assertSame(LDValue.of((long)0).asJsonElement(), LDValue.of((long)0).asJsonElement()); assertSame(LDValue.of((float)0).asJsonElement(), LDValue.of((float)0).asJsonElement()); assertSame(LDValue.of((double)0).asJsonElement(), LDValue.of((double)0).asJsonElement()); assertSame(LDValue.of("").asJsonElement(), LDValue.of("").asJsonElement()); @@ -70,6 +75,8 @@ public void nonBooleanValueAsBooleanIsFalse() { aStringValueFromJsonElement, anIntValue, anIntValueFromJsonElement, + aLongValue, + aLongValueFromJsonElement, aFloatValue, aFloatValueFromJsonElement, aDoubleValue, @@ -101,6 +108,8 @@ public void nonStringValueAsStringIsNull() { aTrueBoolValueFromJsonElement, anIntValue, anIntValueFromJsonElement, + aLongValue, + aLongValueFromJsonElement, aFloatValue, aFloatValueFromJsonElement, aDoubleValue, @@ -125,6 +134,7 @@ public void nullStringConstructorGivesNullInstance() { public void canGetIntegerValueOfAnyNumericType() { LDValue[] values = new LDValue[] { LDValue.of(3), + LDValue.of(3L), LDValue.of(3.0f), LDValue.of(3.25f), LDValue.of(3.75f), @@ -135,6 +145,7 @@ public void canGetIntegerValueOfAnyNumericType() { for (LDValue value: values) { assertEquals(value.toString(), LDValueType.NUMBER, value.getType()); assertEquals(value.toString(), 3, value.intValue()); + assertEquals(value.toString(), 3L, value.longValue()); } } @@ -142,6 +153,7 @@ public void canGetIntegerValueOfAnyNumericType() { public void canGetFloatValueOfAnyNumericType() { LDValue[] values = new LDValue[] { LDValue.of(3), + LDValue.of(3L), LDValue.of(3.0f), LDValue.of(3.0d), }; @@ -155,6 +167,7 @@ public void canGetFloatValueOfAnyNumericType() { public void canGetDoubleValueOfAnyNumericType() { LDValue[] values = new LDValue[] { LDValue.of(3), + LDValue.of(3L), LDValue.of(3.0f), LDValue.of(3.0d), }; @@ -222,6 +235,7 @@ public void nonArrayValuesBehaveLikeEmptyArray() { aTrueBoolValue, aTrueBoolValueFromJsonElement, anIntValue, + aLongValue, aFloatValue, aDoubleValue, aStringValue, @@ -282,6 +296,7 @@ public void nonObjectValuesBehaveLikeEmptyObject() { aTrueBoolValue, aTrueBoolValueFromJsonElement, anIntValue, + aLongValue, aFloatValue, aDoubleValue, aStringValue, @@ -300,12 +315,29 @@ public void nonObjectValuesBehaveLikeEmptyObject() { public void samePrimitivesWithOrWithoutJsonElementAreEqual() { assertEquals(aTrueBoolValue, aTrueBoolValueFromJsonElement); assertEquals(anIntValue, anIntValueFromJsonElement); + assertEquals(aLongValue, aLongValueFromJsonElement); assertEquals(aFloatValue, aFloatValueFromJsonElement); assertEquals(aStringValue, aStringValueFromJsonElement); assertEquals(anArrayValue, anArrayValueFromJsonElement); assertEquals(anObjectValue, anObjectValueFromJsonElement); } + @Test + public void canUseLongTypeForNumberGreaterThanMaxInt() { + long n = (long)Integer.MAX_VALUE + 1; + assertEquals(n, LDValue.of(n).longValue()); + assertEquals(n, LDValue.Convert.Long.toType(LDValue.of(n)).longValue()); + assertEquals(n, LDValue.Convert.Long.fromType(n).longValue()); + } + + @Test + public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { + double n = (double)Float.MAX_VALUE + 1; + assertEquals(n, LDValue.of(n).doubleValue(), 0); + assertEquals(n, LDValue.Convert.Double.toType(LDValue.of(n)).doubleValue(), 0); + assertEquals(n, LDValue.Convert.Double.fromType(n).doubleValue(), 0); + } + @Test public void testToJsonString() { assertEquals("null", LDValue.ofNull().toJsonString()); @@ -314,6 +346,8 @@ public void testToJsonString() { assertEquals("false", LDValue.of(false).toJsonString()); assertEquals(String.valueOf(someInt), anIntValue.toJsonString()); assertEquals(String.valueOf(someInt), anIntValueFromJsonElement.toJsonString()); + assertEquals(String.valueOf(someLong), aLongValue.toJsonString()); + assertEquals(String.valueOf(someLong), aLongValueFromJsonElement.toJsonString()); assertEquals(String.valueOf(someFloat), aFloatValue.toJsonString()); assertEquals(String.valueOf(someFloat), aFloatValueFromJsonElement.toJsonString()); assertEquals(String.valueOf(someDouble), aDoubleValue.toJsonString()); @@ -334,6 +368,8 @@ public void testDefaultGsonSerialization() { aTrueBoolValueFromJsonElement, anIntValue, anIntValueFromJsonElement, + aLongValue, + aLongValueFromJsonElement, aFloatValue, aFloatValueFromJsonElement, aDoubleValue, @@ -356,6 +392,7 @@ public void valueToJsonElement() { assertNull(LDValue.ofNull().asJsonElement()); assertEquals(new JsonPrimitive(true), aTrueBoolValue.asJsonElement()); assertEquals(new JsonPrimitive(someInt), anIntValue.asJsonElement()); + assertEquals(new JsonPrimitive(someLong), aLongValue.asJsonElement()); assertEquals(new JsonPrimitive(someFloat), aFloatValue.asJsonElement()); assertEquals(new JsonPrimitive(someDouble), aDoubleValue.asJsonElement()); assertEquals(new JsonPrimitive(someString), aStringValue.asJsonElement()); @@ -365,6 +402,7 @@ public void valueToJsonElement() { public void testTypeConversions() { testTypeConversion(LDValue.Convert.Boolean, new Boolean[] { true, false }, LDValue.of(true), LDValue.of(false)); testTypeConversion(LDValue.Convert.Integer, new Integer[] { 1, 2 }, LDValue.of(1), LDValue.of(2)); + testTypeConversion(LDValue.Convert.Long, new Long[] { 1L, 2L }, LDValue.of(1L), LDValue.of(2L)); testTypeConversion(LDValue.Convert.Float, new Float[] { 1.5f, 2.5f }, LDValue.of(1.5f), LDValue.of(2.5f)); testTypeConversion(LDValue.Convert.Double, new Double[] { 1.5d, 2.5d }, LDValue.of(1.5d), LDValue.of(2.5d)); testTypeConversion(LDValue.Convert.String, new String[] { "a", "b" }, LDValue.of("a"), LDValue.of("b")); From c44ae918fd8ac351feffdcbaab9b8de23ea21da7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 2 Oct 2019 18:21:30 -0700 Subject: [PATCH 181/327] javadoc fixes --- src/main/java/com/launchdarkly/client/value/LDValue.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index 3cf00f514..7d7ee6abc 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -323,6 +323,7 @@ public Iterable values() { * for (int i: anArrayOfInts.valuesAs(LDValue.Convert.Integer)) { println(i); } * * + * @param the desired type * @param converter the {@link Converter} for the specified type * @return an iterable of values of the specified type */ @@ -514,7 +515,7 @@ public static abstract class Converter { *

    * Example: *

    
    -     *     List listOfInts = ImmutableList.builder().add(1).add(2).add(3).build();
    +     *     List<Integer> listOfInts = ImmutableList.<Integer>builder().add(1).add(2).add(3).build();
          *     LDValue arrayValue = LDValue.Convert.Integer.arrayFrom(listOfInts);
          * 
    * @@ -560,7 +561,7 @@ public LDValue arrayOf(T... values) { *

    * Example: *

    
    -     *     Map mapOfInts = ImmutableMap.builder().put("a", 1).build();
    +     *     Map<String, Integer> mapOfInts = ImmutableMap.<String, Integer>builder().put("a", 1).build();
          *     LDValue objectValue = LDValue.Convert.Integer.objectFrom(mapOfInts);
          * 
    * From bb5316f5aa5101547ecb0832410f8a5784bc2b26 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 2 Oct 2019 18:30:45 -0700 Subject: [PATCH 182/327] simplify EvaluationDetail --- .../launchdarkly/client/EvaluationDetail.java | 100 +----------------- .../com/launchdarkly/client/FeatureFlag.java | 4 +- .../com/launchdarkly/client/LDClient.java | 12 +-- .../client/DefaultEventProcessorTest.java | 5 +- .../launchdarkly/client/FeatureFlagTest.java | 24 ++--- .../client/FeatureFlagsStateTest.java | 23 ++-- .../client/LDClientEvaluationTest.java | 14 +-- .../com/launchdarkly/client/TestUtil.java | 2 +- 8 files changed, 47 insertions(+), 137 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index 2f3cd9ead..a1098b025 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -1,7 +1,6 @@ package com.launchdarkly.client; import com.google.common.base.Objects; -import com.google.gson.JsonElement; import com.launchdarkly.client.value.LDValue; /** @@ -15,39 +14,20 @@ public class EvaluationDetail { private final EvaluationReason reason; private final Integer variationIndex; private final T value; - private final LDValue jsonValue; /** - * Constructs an instance without the {@code jsonValue} property. + * Constructs an instance with all properties specified. * * @param reason an {@link EvaluationReason} (should not be null) * @param variationIndex an optional variation index * @param value a value of the desired type */ - @Deprecated public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value) { this.value = value; - this.jsonValue = toLDValue(value); this.variationIndex = variationIndex; this.reason = reason; } - /** - * Constructs an instance with all properties specified. - * - * @param reason an {@link EvaluationReason} (should not be null) - * @param variationIndex an optional variation index - * @param value a value of the desired type - * @param jsonValue the {@link LDValue} representation of the value - * @since 4.8.0 - */ - private EvaluationDetail(T value, LDValue jsonValue, Integer variationIndex, EvaluationReason reason) { - this.value = value; - this.jsonValue = jsonValue == null ? LDValue.ofNull() : jsonValue; - this.variationIndex = variationIndex; - this.reason = reason; - } - /** * Factory method for an arbitrary value. * @@ -58,70 +38,11 @@ private EvaluationDetail(T value, LDValue jsonValue, Integer variationIndex, Eva * @since 4.8.0 */ public static EvaluationDetail fromValue(T value, Integer variationIndex, EvaluationReason reason) { - return new EvaluationDetail<>(value, toLDValue(value), variationIndex, reason); - } - - /** - * Factory method for using an {@link LDValue} as the value. - * - * @param jsonValue a value - * @param variationIndex an optional variation index - * @param reason an {@link EvaluationReason} (should not be null) - * @return an {@link EvaluationDetail} - * @since 4.8.0 - */ - public static EvaluationDetail fromJsonValue(LDValue jsonValue, Integer variationIndex, EvaluationReason reason) { - return new EvaluationDetail<>(jsonValue, jsonValue, variationIndex, reason); - } - - /** - * Factory method for an arbitrary value that also specifies it as a {@link LDValue}. - * - * @param value a value of the desired type - * @param jsonValue the same value represented as an {@link LDValue} - * @param variationIndex an optional variation index - * @param reason an {@link EvaluationReason} (should not be null) - * @return an {@link EvaluationDetail} - * @since 4.8.0 - */ - public static EvaluationDetail fromValueWithJsonValue(T value, LDValue jsonValue, Integer variationIndex, EvaluationReason reason) { - return new EvaluationDetail<>(value, jsonValue, variationIndex, reason); + return new EvaluationDetail(reason, variationIndex, value); } static EvaluationDetail error(EvaluationReason.ErrorKind errorKind, LDValue defaultValue) { - return new EvaluationDetail<>(defaultValue == null ? LDValue.ofNull() : defaultValue, defaultValue, null, EvaluationReason.error(errorKind)); - } - - @SuppressWarnings("deprecation") - private static LDValue toLDValue(Object value) { - if (value == null) { - return LDValue.ofNull(); - } - if (value instanceof LDValue) { - return (LDValue)value; - } - if (value instanceof JsonElement) { - return LDValue.fromJsonElement((JsonElement)value); - } - if (value instanceof Boolean) { - return LDValue.of(((Boolean)value).booleanValue()); - } - if (value instanceof Integer) { - return LDValue.of(((Integer)value).intValue()); - } - if (value instanceof Long) { - return LDValue.of(((Long)value).longValue()); - } - if (value instanceof Float) { - return LDValue.of(((Float)value).floatValue()); - } - if (value instanceof Double) { - return LDValue.of(((Double)value).doubleValue()); - } - if (value instanceof String) { - return LDValue.of((String)value); - } - return LDValue.ofNull(); + return new EvaluationDetail(EvaluationReason.error(errorKind), null, LDValue.normalize(defaultValue)); } /** @@ -150,16 +71,6 @@ public T getValue() { return value; } - /** - * The result of the flag evaluation as an {@link LDValue}. 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 - * @since 4.8.0 - */ - public LDValue getJsonValue() { - return jsonValue; - } - /** * Returns true if the flag evaluation returned the default value, rather than one of the flag's * variations. @@ -174,15 +85,14 @@ public boolean equals(Object other) { if (other instanceof EvaluationDetail) { @SuppressWarnings("unchecked") EvaluationDetail o = (EvaluationDetail)other; - return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value) - && Objects.equal(jsonValue, o.jsonValue); + return Objects.equal(reason, o.reason) && Objects.equal(variationIndex, o.variationIndex) && Objects.equal(value, o.value); } return false; } @Override public int hashCode() { - return Objects.hashCode(reason, variationIndex, value, jsonValue); + return Objects.hashCode(reason, variationIndex, value); } @Override diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index aff0b75ce..62a9786a5 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -138,12 +138,12 @@ private EvaluationDetail getVariation(int variation, EvaluationReason r } LDValue value = LDValue.normalize(variations.get(variation)); // normalize() ensures that nulls become LDValue.ofNull() - Gson may give us nulls - return EvaluationDetail.fromJsonValue(value, variation, reason); + return EvaluationDetail.fromValue(value, variation, reason); } private EvaluationDetail getOffValue(EvaluationReason reason) { if (offVariation == null) { // off variation unspecified - return default value - return EvaluationDetail.fromJsonValue(LDValue.ofNull(), null, reason); + return EvaluationDetail.fromValue(LDValue.ofNull(), null, reason); } return getVariation(offVariation, reason); } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 893080d5a..d03663a11 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -237,7 +237,7 @@ public LDValue jsonValueVariation(String featureKey, LDUser user, LDValue defaul public EvaluationDetail boolVariationDetail(String featureKey, LDUser user, boolean defaultValue) { EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().booleanValue(), details.getJsonValue(), + return EvaluationDetail.fromValue(details.getValue().booleanValue(), details.getVariationIndex(), details.getReason()); } @@ -245,7 +245,7 @@ public EvaluationDetail boolVariationDetail(String featureKey, LDUser u public EvaluationDetail intVariationDetail(String featureKey, LDUser user, int defaultValue) { EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().intValue(), details.getJsonValue(), + return EvaluationDetail.fromValue(details.getValue().intValue(), details.getVariationIndex(), details.getReason()); } @@ -253,7 +253,7 @@ public EvaluationDetail intVariationDetail(String featureKey, LDUser us public EvaluationDetail doubleVariationDetail(String featureKey, LDUser user, double defaultValue) { EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().doubleValue(), details.getJsonValue(), + return EvaluationDetail.fromValue(details.getValue().doubleValue(), details.getVariationIndex(), details.getReason()); } @@ -261,7 +261,7 @@ public EvaluationDetail doubleVariationDetail(String featureKey, LDUser public EvaluationDetail stringVariationDetail(String featureKey, LDUser user, String defaultValue) { EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.of(defaultValue), true, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().stringValue(), details.getJsonValue(), + return EvaluationDetail.fromValue(details.getValue().stringValue(), details.getVariationIndex(), details.getReason()); } @@ -270,7 +270,7 @@ public EvaluationDetail stringVariationDetail(String featureKey, LDUser public EvaluationDetail jsonVariationDetail(String featureKey, LDUser user, JsonElement defaultValue) { EvaluationDetail details = evaluateDetail(featureKey, user, LDValue.unsafeFromJsonElement(defaultValue), false, EventFactory.DEFAULT_WITH_REASONS); - return EvaluationDetail.fromValueWithJsonValue(details.getJsonValue().asUnsafeJsonElement(), details.getJsonValue(), + return EvaluationDetail.fromValue(details.getValue().asUnsafeJsonElement(), details.getVariationIndex(), details.getReason()); } @@ -354,7 +354,7 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser use } EvaluationDetail details = evalResult.getDetails(); if (details.isDefaultValue()) { - details = EvaluationDetail.fromJsonValue(defaultValue, null, details.getReason()); + details = EvaluationDetail.fromValue(defaultValue, null, details.getReason()); } sendFlagRequestEvent(eventFactory.newFeatureRequestEvent(featureFlag, user, details, defaultValue)); return details; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 4cde9969e..6e0a86de2 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -3,8 +3,6 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; -import com.google.gson.JsonPrimitive; -import com.launchdarkly.client.DefaultEventProcessor.EventDispatcher; import com.launchdarkly.client.value.LDValue; import org.hamcrest.Matcher; @@ -32,6 +30,7 @@ import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +@SuppressWarnings("javadoc") public class DefaultEventProcessorTest { private static final String SDK_KEY = "SDK_KEY"; private static final LDUser user = new LDUser.Builder("userkey").name("Red").build(); @@ -167,7 +166,7 @@ public void featureEventCanContainReason() throws Exception { 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, - EvaluationDetail.fromJsonValue(LDValue.of("value"), 1, reason), LDValue.ofNull()); + EvaluationDetail.fromValue(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 5da885b00..17328ed83 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -10,7 +10,7 @@ import java.util.Arrays; -import static com.launchdarkly.client.EvaluationDetail.fromJsonValue; +import static com.launchdarkly.client.EvaluationDetail.fromValue; import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; import static com.launchdarkly.client.TestUtil.fallthroughVariation; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -40,7 +40,7 @@ public void flagReturnsOffVariationIfFlagIsOff() throws Exception { .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(fromJsonValue(LDValue.of("off"), 1, EvaluationReason.off()), result.getDetails()); + assertEquals(fromValue(LDValue.of("off"), 1, EvaluationReason.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(fromJsonValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); + assertEquals(fromValue(LDValue.ofNull(), null, EvaluationReason.off()), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -95,7 +95,7 @@ public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exceptio .build(); FeatureFlag.EvalResult result = f.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(fromJsonValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -168,7 +168,7 @@ public void flagReturnsOffVariationIfPrerequisiteIsNotFound() throws Exception { FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromJsonValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -194,7 +194,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsOff() throws Exceptio FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromJsonValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -224,7 +224,7 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); - assertEquals(fromJsonValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); + assertEquals(fromValue(LDValue.of("off"), 1, expectedReason), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -253,7 +253,7 @@ public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAr featureStore.upsert(FEATURES, f1); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(fromJsonValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); assertEquals(1, result.getPrerequisiteEvents().size()); Event.FeatureRequest event = result.getPrerequisiteEvents().get(0); @@ -290,7 +290,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio featureStore.upsert(FEATURES, f2); FeatureFlag.EvalResult result = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); - assertEquals(fromJsonValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); + assertEquals(fromValue(LDValue.of("fall"), 0, EvaluationReason.fallthrough()), result.getDetails()); assertEquals(2, result.getPrerequisiteEvents().size()); Event.FeatureRequest event0 = result.getPrerequisiteEvents().get(0); @@ -318,7 +318,7 @@ public void flagMatchesUserFromTargets() throws Exception { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(fromJsonValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.targetMatch()), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -332,7 +332,7 @@ public void flagMatchesUserFromRules() { LDUser user = new LDUser.Builder("userkey").build(); FeatureFlag.EvalResult result = f.evaluate(user, featureStore, EventFactory.DEFAULT); - assertEquals(fromJsonValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); + assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } @@ -461,7 +461,7 @@ public void clauseWithNullOperatorDoesNotStopSubsequentRuleFromMatching() throws LDUser user = new LDUser.Builder("key").name("Bob").build(); EvaluationDetail details = f.evaluate(user, featureStore, EventFactory.DEFAULT).getDetails(); - assertEquals(fromJsonValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); + assertEquals(fromValue(LDValue.of(true), 1, EvaluationReason.ruleMatch(1, "rule2")), details); } @Test diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java index c7a831bb9..92e4cfc0d 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagsStateTest.java @@ -7,17 +7,18 @@ import org.junit.Test; -import static com.launchdarkly.client.EvaluationDetail.fromJsonValue; +import static com.launchdarkly.client.EvaluationDetail.fromValue; import static com.launchdarkly.client.TestUtil.js; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +@SuppressWarnings("javadoc") public class FeatureFlagsStateTest { private static final Gson gson = new Gson(); @Test public void canGetFlagValue() { - EvaluationDetail eval = fromJsonValue(LDValue.of("value"), 1, EvaluationReason.off()); + EvaluationDetail eval = fromValue(LDValue.of("value"), 1, EvaluationReason.off()); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); @@ -33,7 +34,7 @@ public void unknownFlagReturnsNullValue() { @Test public void canGetFlagReason() { - EvaluationDetail eval = fromJsonValue(LDValue.of("value1"), 1, EvaluationReason.off()); + EvaluationDetail eval = fromValue(LDValue.of("value1"), 1, EvaluationReason.off()); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag, eval).build(); @@ -50,7 +51,7 @@ public void unknownFlagReturnsNullReason() { @Test public void reasonIsNullIfReasonsWereNotRecorded() { - EvaluationDetail eval = fromJsonValue(LDValue.of("value1"), 1, EvaluationReason.off()); + EvaluationDetail eval = fromValue(LDValue.of("value1"), 1, EvaluationReason.off()); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); @@ -59,7 +60,7 @@ public void reasonIsNullIfReasonsWereNotRecorded() { @Test public void flagCanHaveNullValue() { - EvaluationDetail eval = fromJsonValue(LDValue.ofNull(), 1, null); + EvaluationDetail eval = fromValue(LDValue.ofNull(), 1, null); FeatureFlag flag = new FeatureFlagBuilder("key").build(); FeatureFlagsState state = new FeatureFlagsState.Builder().addFlag(flag, eval).build(); @@ -68,9 +69,9 @@ public void flagCanHaveNullValue() { @Test public void canConvertToValuesMap() { - EvaluationDetail eval1 = fromJsonValue(LDValue.of("value1"), 0, EvaluationReason.off()); + EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); FeatureFlag flag1 = new FeatureFlagBuilder("key1").build(); - EvaluationDetail eval2 = fromJsonValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); FeatureFlag flag2 = new FeatureFlagBuilder("key2").build(); FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -81,9 +82,9 @@ public void canConvertToValuesMap() { @Test public void canConvertToJson() { - EvaluationDetail eval1 = fromJsonValue(LDValue.of("value1"), 0, EvaluationReason.off()); + EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = fromJsonValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); + EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.fallthrough()); FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); FeatureFlagsState state = new FeatureFlagsState.Builder(FlagsStateOption.WITH_REASONS) .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); @@ -104,9 +105,9 @@ public void canConvertToJson() { @Test public void canConvertFromJson() { - EvaluationDetail eval1 = fromJsonValue(LDValue.of("value1"), 0, EvaluationReason.off()); + EvaluationDetail eval1 = fromValue(LDValue.of("value1"), 0, EvaluationReason.off()); FeatureFlag flag1 = new FeatureFlagBuilder("key1").version(100).trackEvents(false).build(); - EvaluationDetail eval2 = fromJsonValue(LDValue.of("value2"), 1, EvaluationReason.off()); + EvaluationDetail eval2 = fromValue(LDValue.of("value2"), 1, EvaluationReason.off()); FeatureFlag flag2 = new FeatureFlagBuilder("key2").version(200).trackEvents(true).debugEventsUntilDate(1000L).build(); FeatureFlagsState state = new FeatureFlagsState.Builder() .addFlag(flag1, eval1).addFlag(flag2, eval2).build(); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index fe4c5f14c..501e7c11f 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -193,7 +193,7 @@ public void canMatchUserBySegment() throws Exception { public void canGetDetailsForSuccessfulEvaluation() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(true, LDValue.of(true), + EvaluationDetail expectedResult = EvaluationDetail.fromValue(true, 0, EvaluationReason.off()); assertEquals(expectedResult, client.boolVariationDetail("key", user, false)); } @@ -211,7 +211,7 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { FeatureFlag flag = new FeatureFlagBuilder("key").on(false).offVariation(null).build(); featureStore.upsert(FEATURES, flag); - EvaluationDetail expected = EvaluationDetail.fromValueWithJsonValue("default", LDValue.of("default"), + EvaluationDetail expected = EvaluationDetail.fromValue("default", null, EvaluationReason.off()); EvaluationDetail actual = client.stringVariationDetail("key", user, "default"); assertEquals(expected, actual); @@ -228,7 +228,7 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { .startWaitMillis(0) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(false, LDValue.of(false), null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } @@ -236,7 +236,7 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { @Test public void appropriateErrorIfFlagDoesNotExist() throws Exception { - EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue("default", LDValue.of("default"), null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND)); assertEquals(expectedResult, client.stringVariationDetail("key", user, "default")); } @@ -245,7 +245,7 @@ public void appropriateErrorIfFlagDoesNotExist() throws Exception { public void appropriateErrorIfUserNotSpecified() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue("default", LDValue.of("default"), null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue("default", null, EvaluationReason.error(EvaluationReason.ErrorKind.USER_NOT_SPECIFIED)); assertEquals(expectedResult, client.stringVariationDetail("key", null, "default")); } @@ -254,7 +254,7 @@ public void appropriateErrorIfUserNotSpecified() throws Exception { public void appropriateErrorIfValueWrongType() throws Exception { featureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); - EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(3, LDValue.of(3), null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(3, null, EvaluationReason.error(EvaluationReason.ErrorKind.WRONG_TYPE)); assertEquals(expectedResult, client.intVariationDetail("key", user, 3)); } @@ -268,7 +268,7 @@ public void appropriateErrorForUnexpectedException() throws Exception { .updateProcessorFactory(Components.nullUpdateProcessor()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { - EvaluationDetail expectedResult = EvaluationDetail.fromValueWithJsonValue(false, LDValue.of(false), null, + EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION)); 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 b576fe92f..cd963461a 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -208,7 +208,7 @@ public DataBuilder add(VersionedDataKind kind, VersionedData... items) { } public static EvaluationDetail simpleEvaluation(int variation, LDValue value) { - return EvaluationDetail.fromJsonValue(value, variation, EvaluationReason.fallthrough()); + return EvaluationDetail.fromValue(value, variation, EvaluationReason.fallthrough()); } public static Matcher hasJsonProperty(final String name, JsonElement value) { From 0367d076dfc2b947c516c644f42673e4f61bafae Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 2 Oct 2019 18:36:41 -0700 Subject: [PATCH 183/327] fail if test jar download fails --- packaging-test/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 156e4e17e..e81035adb 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -145,10 +145,10 @@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all rm $@/gson*.jar $@/slf4j*.jar $(SLF4J_SIMPLE_JAR): | $(TEMP_DIR) - curl $(SLF4J_SIMPLE_JAR_URL) >$@ + curl -f $(SLF4J_SIMPLE_JAR_URL) >$@ $(FELIX_JAR): | $(TEMP_DIR) - curl $(FELIX_ARCHIVE_URL) >$(TEMP_DIR)/$(FELIX_ARCHIVE) + curl -f $(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 da4dde5c09337cc1fb6e76902a6ead47fde26e36 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 2 Oct 2019 18:45:52 -0700 Subject: [PATCH 184/327] fix broken test job - jar URLs are now returning redirects --- packaging-test/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 156e4e17e..e69d2cf40 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -145,10 +145,10 @@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all rm $@/gson*.jar $@/slf4j*.jar $(SLF4J_SIMPLE_JAR): | $(TEMP_DIR) - curl $(SLF4J_SIMPLE_JAR_URL) >$@ + curl -f -L $(SLF4J_SIMPLE_JAR_URL) >$@ $(FELIX_JAR): | $(TEMP_DIR) - curl $(FELIX_ARCHIVE_URL) >$(TEMP_DIR)/$(FELIX_ARCHIVE) + curl -f -L $(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 41d52f07998687291247c695ab63124299bf9ff1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 7 Oct 2019 15:29:13 -0700 Subject: [PATCH 185/327] indent --- .../java/com/launchdarkly/client/value/LDValueJsonElement.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java index 586c0fcaf..34f0cb8bb 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java +++ b/src/main/java/com/launchdarkly/client/value/LDValueJsonElement.java @@ -115,7 +115,8 @@ public int size() { return value.getAsJsonArray().size(); case OBJECT: return value.getAsJsonObject().size(); - default: return 0; + default: + return 0; } } From 745633c5eb41c4d90702d35df621f2e4289c100b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 7 Oct 2019 16:16:02 -0700 Subject: [PATCH 186/327] add missing @Deprecated --- src/main/java/com/launchdarkly/client/value/LDValue.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/launchdarkly/client/value/LDValue.java b/src/main/java/com/launchdarkly/client/value/LDValue.java index 7d7ee6abc..996e7b41d 100644 --- a/src/main/java/com/launchdarkly/client/value/LDValue.java +++ b/src/main/java/com/launchdarkly/client/value/LDValue.java @@ -374,6 +374,7 @@ public String toJsonString() { * @deprecated The Gson types may be removed from the public API at some point; it is preferable to * use getters like {@link #booleanValue()} and {@link #getType()}. */ + @Deprecated public JsonElement asJsonElement() { return LDValueJsonElement.deepCopy(asUnsafeJsonElement()); } From a6d1877d37a5776987f0e020aa654ecc5055fa4e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 7 Oct 2019 16:24:23 -0700 Subject: [PATCH 187/327] add package comments --- .../java/com/launchdarkly/client/files/package-info.java | 6 ++++++ src/main/java/com/launchdarkly/client/package-info.java | 8 ++++++++ .../java/com/launchdarkly/client/utils/package-info.java | 4 ++++ .../java/com/launchdarkly/client/value/package-info.java | 4 ++++ 4 files changed, 22 insertions(+) create mode 100644 src/main/java/com/launchdarkly/client/files/package-info.java create mode 100644 src/main/java/com/launchdarkly/client/package-info.java create mode 100644 src/main/java/com/launchdarkly/client/utils/package-info.java create mode 100644 src/main/java/com/launchdarkly/client/value/package-info.java diff --git a/src/main/java/com/launchdarkly/client/files/package-info.java b/src/main/java/com/launchdarkly/client/files/package-info.java new file mode 100644 index 000000000..a5a3eafa4 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/files/package-info.java @@ -0,0 +1,6 @@ +/** + * Package for the file data source component, which may be useful in tests. + *

    + * The entry point is {@link com.launchdarkly.client.files.FileComponents}. + */ +package com.launchdarkly.client.files; diff --git a/src/main/java/com/launchdarkly/client/package-info.java b/src/main/java/com/launchdarkly/client/package-info.java new file mode 100644 index 000000000..14ba4590e --- /dev/null +++ b/src/main/java/com/launchdarkly/client/package-info.java @@ -0,0 +1,8 @@ +/** + * The main package for the LaunchDarkly Java SDK. + *

    + * You will most often use {@link com.launchdarkly.client.LDClient} (the SDK client), + * {@link com.launchdarkly.client.LDConfig} (configuration options for the client), and + * {@link com.launchdarkly.client.LDUser} (user properties for feature flag evaluation). + */ +package com.launchdarkly.client; diff --git a/src/main/java/com/launchdarkly/client/utils/package-info.java b/src/main/java/com/launchdarkly/client/utils/package-info.java new file mode 100644 index 000000000..5be71fa92 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/utils/package-info.java @@ -0,0 +1,4 @@ +/** + * Helper classes that may be useful in custom integrations. + */ +package com.launchdarkly.client.utils; diff --git a/src/main/java/com/launchdarkly/client/value/package-info.java b/src/main/java/com/launchdarkly/client/value/package-info.java new file mode 100644 index 000000000..59e453f22 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/value/package-info.java @@ -0,0 +1,4 @@ +/** + * Provides the {@link com.launchdarkly.client.value.LDValue} abstraction for supported data types. + */ +package com.launchdarkly.client.value; From b70ab0fe5ff41193a1a2f96136c3e51c9cd7fb4d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 7 Oct 2019 16:54:50 -0700 Subject: [PATCH 188/327] restore previously existing track() overload --- .../com/launchdarkly/client/LDClient.java | 8 +++- .../client/LDClientInterface.java | 23 +++++++++++- .../client/LDClientEventTest.java | 37 ++++++++++++++++++- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index d03663a11..25e9a4006 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -129,8 +129,14 @@ public void track(String eventName, LDUser user, JsonElement data) { trackData(eventName, user, LDValue.unsafeFromJsonElement(data)); } + @SuppressWarnings("deprecation") + @Override + public void track(String eventName, LDUser user, JsonElement data, double metricValue) { + trackMetric(eventName, user, LDValue.unsafeFromJsonElement(data), metricValue); + } + @Override - public void track(String eventName, LDUser user, LDValue data, double metricValue) { + public void trackMetric(String eventName, LDUser user, LDValue data, double metricValue) { if (user == null || user.getKeyAsString() == null) { logger.warn("Track called with null user or null user key!"); } else { diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index 0516f4a47..d3f371a4e 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -54,14 +54,33 @@ public interface LDClientInterface extends Closeable { * * @param eventName the name of the event * @param user the user that performed the event + * @param data a JSON object containing additional data associated with the event; may be null + * @param metricValue a numeric value used by the LaunchDarkly experimentation feature in numeric custom + * metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be + * returned as part of the custom event for Data Export. + * @since 4.8.0 + * @deprecated Use {@link #trackMetric(String, LDUser, LDValue, double)}. + */ + void track(String eventName, LDUser user, JsonElement data, double metricValue); + + /** + * Tracks that a user performed an event, and provides an additional numeric value for custom metrics. + *

    + * As of this version’s release date, the LaunchDarkly service does not support the {@code metricValue} + * parameter. As a result, calling this overload of {@code track} will not yet produce any different + * behavior from calling {@link #trackData(String, LDUser, LDValue)} without a {@code metricValue}. + * Refer to the SDK reference guide for the latest status. + * + * @param eventName the name of the event + * @param user the user that performed the event * @param data an {@link LDValue} containing additional data associated with the event; if not applicable, * you may pass either {@code null} or {@link LDValue#ofNull()} * @param metricValue a numeric value used by the LaunchDarkly experimentation feature in numeric custom * metrics. Can be omitted if this event is used by only non-numeric metrics. This field will also be * returned as part of the custom event for Data Export. - * @since 4.8.0 + * @since 4.9.0 */ - void track(String eventName, LDUser user, LDValue data, double metricValue); + void trackMetric(String eventName, LDUser user, LDValue data, double metricValue); /** * Registers the user. diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 10b48cc89..f71a56bf3 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.EvaluationReason.ErrorKind; import com.launchdarkly.client.value.LDValue; @@ -88,7 +89,7 @@ public void trackSendsEventWithData() throws Exception { public void trackSendsEventWithDataAndMetricValue() throws Exception { LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); double metricValue = 1.5; - client.track("eventkey", user, data, metricValue); + client.trackMetric("eventkey", user, data, metricValue); assertEquals(1, eventSink.events.size()); Event e = eventSink.events.get(0); @@ -99,7 +100,39 @@ public void trackSendsEventWithDataAndMetricValue() throws Exception { assertEquals(data, ce.data); assertEquals(new Double(metricValue), ce.metricValue); } - + + @SuppressWarnings("deprecation") + @Test + public void deprecatedTrackSendsEventWithData() throws Exception { + JsonElement data = new JsonPrimitive("stuff"); + client.track("eventkey", user, data); + + assertEquals(1, eventSink.events.size()); + Event e = eventSink.events.get(0); + assertEquals(Event.Custom.class, e.getClass()); + Event.Custom ce = (Event.Custom)e; + assertEquals(user.getKey(), ce.user.getKey()); + assertEquals("eventkey", ce.key); + assertEquals(data, ce.data.asJsonElement()); + } + + @SuppressWarnings("deprecation") + @Test + public void deprecatedTrackSendsEventWithDataAndMetricValue() throws Exception { + JsonElement data = new JsonPrimitive("stuff"); + double metricValue = 1.5; + client.track("eventkey", user, data, metricValue); + + assertEquals(1, eventSink.events.size()); + Event e = eventSink.events.get(0); + assertEquals(Event.Custom.class, e.getClass()); + Event.Custom ce = (Event.Custom)e; + assertEquals(user.getKey(), ce.user.getKey()); + assertEquals("eventkey", ce.key); + assertEquals(data, ce.data.asJsonElement()); + assertEquals(new Double(metricValue), ce.metricValue); + } + @Test public void trackWithNullUserDoesNotSendEvent() { client.track("eventkey", null); From 50e0eb63b0a868459e01f91da53a12f44d128c76 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 7 Oct 2019 17:02:46 -0700 Subject: [PATCH 189/327] add @Deprecated --- src/main/java/com/launchdarkly/client/LDClientInterface.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index d3f371a4e..d8e2154b1 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -61,6 +61,7 @@ public interface LDClientInterface extends Closeable { * @since 4.8.0 * @deprecated Use {@link #trackMetric(String, LDUser, LDValue, double)}. */ + @Deprecated void track(String eventName, LDUser user, JsonElement data, double metricValue); /** From ab4cfeaebf5267f29900acc2468cc4c8b9d2677e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 15 Oct 2019 13:18:53 -0700 Subject: [PATCH 190/327] add packaging test for NewRelic reflection --- .../shaded/com/newrelic/api/agent/NewRelic.java | 9 +++++++++ .../src/main/java/com/newrelic/api/agent/NewRelic.java | 8 ++++++++ .../test-app/src/main/java/testapp/TestApp.java | 3 +++ 3 files changed, 20 insertions(+) create mode 100644 packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java create mode 100644 packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java diff --git a/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java b/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java new file mode 100644 index 000000000..83bae9f3b --- /dev/null +++ b/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java @@ -0,0 +1,9 @@ +package com.launchdarkly.shaded.com.newrelic.api.agent; + +// Test to verify fix for https://github.com/launchdarkly/java-server-sdk/issues/171 +public class NewRelic { + public static void addCustomParameter(String name, String value) { + System.out.println("NewRelic class reference was shaded! Test app loaded " + NewRelic.class.getName()); + System.exit(1); // forces test failure + } +} diff --git a/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java b/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java new file mode 100644 index 000000000..5b106c460 --- /dev/null +++ b/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java @@ -0,0 +1,8 @@ +package com.newrelic.api.agent; + +// Test to verify fix for https://github.com/launchdarkly/java-server-sdk/issues/171 +public class NewRelic { + public static void addCustomParameter(String name, String value) { + System.out.println("NewRelic class reference was correctly resolved without shading"); + } +} 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 f56f1207e..200b2f4e2 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -18,6 +18,9 @@ public static void main(String[] args) throws Exception { // that provides its own copy of Gson). JsonPrimitive x = new JsonPrimitive("x"); + // Also do a flag evaluation, to ensure that it calls NewRelicReflector.annotateTransaction() + client.boolVariation("flag-key", new LDUser("user-key"), false); + System.out.println("@@@ successfully created LD client @@@"); } } \ No newline at end of file From 4ccd69966b9e9295ea9eb89f08ae0251e01d3bc8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 15 Oct 2019 13:23:06 -0700 Subject: [PATCH 191/327] fix NewRelic reflection logic to avoid shading --- .../launchdarkly/client/NewRelicReflector.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/NewRelicReflector.java b/src/main/java/com/launchdarkly/client/NewRelicReflector.java index 8a9c1c0cb..91a09c52c 100644 --- a/src/main/java/com/launchdarkly/client/NewRelicReflector.java +++ b/src/main/java/com/launchdarkly/client/NewRelicReflector.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.google.common.base.Joiner; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,17 +15,24 @@ final class NewRelicReflector { private static final Logger logger = LoggerFactory.getLogger(NewRelicReflector.class); - static { try { - newRelic = Class.forName("com.newrelic.api.agent.NewRelic"); + newRelic = Class.forName(getNewRelicClassName()); addCustomParameter = newRelic.getDeclaredMethod("addCustomParameter", String.class, String.class); } catch (ClassNotFoundException | NoSuchMethodException e) { logger.info("No NewRelic agent detected"); } } - static void annotateTransaction(String featureKey, String value) { + static String getNewRelicClassName() { + // This ungainly logic is a workaround for the overly aggressive behavior of the Shadow plugin, which + // will transform any class or package names passed to Class.forName() if they are string literals; + // it will even transform the string "com". + String com = Joiner.on("").join(new String[] { "c", "o", "m" }); + return Joiner.on(".").join(new String[] { com, "newrelic", "api", "agent", "NewRelic" }); + } + + static void annotateTransaction(String featureKey, String value) { if (addCustomParameter != null) { try { addCustomParameter.invoke(null, featureKey, value); @@ -32,6 +41,5 @@ static void annotateTransaction(String featureKey, String value) { logger.debug(e.toString(), e); } } - } - + } } From 014100ac340eea3e8dbc3da0b9a3623aa3330031 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 15 Oct 2019 17:09:41 -0700 Subject: [PATCH 192/327] fix NewRelic reflection logic to avoid shading --- .../com/newrelic/api/agent/NewRelic.java | 9 +++++++++ .../java/com/newrelic/api/agent/NewRelic.java | 8 ++++++++ .../src/main/java/testapp/TestApp.java | 3 +++ .../launchdarkly/client/NewRelicReflector.java | 18 +++++++++++++----- 4 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java create mode 100644 packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java diff --git a/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java b/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java new file mode 100644 index 000000000..83bae9f3b --- /dev/null +++ b/packaging-test/test-app/src/main/java/com/launchdarkly/shaded/com/newrelic/api/agent/NewRelic.java @@ -0,0 +1,9 @@ +package com.launchdarkly.shaded.com.newrelic.api.agent; + +// Test to verify fix for https://github.com/launchdarkly/java-server-sdk/issues/171 +public class NewRelic { + public static void addCustomParameter(String name, String value) { + System.out.println("NewRelic class reference was shaded! Test app loaded " + NewRelic.class.getName()); + System.exit(1); // forces test failure + } +} diff --git a/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java b/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java new file mode 100644 index 000000000..5b106c460 --- /dev/null +++ b/packaging-test/test-app/src/main/java/com/newrelic/api/agent/NewRelic.java @@ -0,0 +1,8 @@ +package com.newrelic.api.agent; + +// Test to verify fix for https://github.com/launchdarkly/java-server-sdk/issues/171 +public class NewRelic { + public static void addCustomParameter(String name, String value) { + System.out.println("NewRelic class reference was correctly resolved without shading"); + } +} 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 f56f1207e..200b2f4e2 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -18,6 +18,9 @@ public static void main(String[] args) throws Exception { // that provides its own copy of Gson). JsonPrimitive x = new JsonPrimitive("x"); + // Also do a flag evaluation, to ensure that it calls NewRelicReflector.annotateTransaction() + client.boolVariation("flag-key", new LDUser("user-key"), false); + System.out.println("@@@ successfully created LD client @@@"); } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/NewRelicReflector.java b/src/main/java/com/launchdarkly/client/NewRelicReflector.java index 8a9c1c0cb..91a09c52c 100644 --- a/src/main/java/com/launchdarkly/client/NewRelicReflector.java +++ b/src/main/java/com/launchdarkly/client/NewRelicReflector.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.google.common.base.Joiner; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,17 +15,24 @@ final class NewRelicReflector { private static final Logger logger = LoggerFactory.getLogger(NewRelicReflector.class); - static { try { - newRelic = Class.forName("com.newrelic.api.agent.NewRelic"); + newRelic = Class.forName(getNewRelicClassName()); addCustomParameter = newRelic.getDeclaredMethod("addCustomParameter", String.class, String.class); } catch (ClassNotFoundException | NoSuchMethodException e) { logger.info("No NewRelic agent detected"); } } - static void annotateTransaction(String featureKey, String value) { + static String getNewRelicClassName() { + // This ungainly logic is a workaround for the overly aggressive behavior of the Shadow plugin, which + // will transform any class or package names passed to Class.forName() if they are string literals; + // it will even transform the string "com". + String com = Joiner.on("").join(new String[] { "c", "o", "m" }); + return Joiner.on(".").join(new String[] { com, "newrelic", "api", "agent", "NewRelic" }); + } + + static void annotateTransaction(String featureKey, String value) { if (addCustomParameter != null) { try { addCustomParameter.invoke(null, featureKey, value); @@ -32,6 +41,5 @@ static void annotateTransaction(String featureKey, String value) { logger.debug(e.toString(), e); } } - } - + } } From bd7cfef41d2f4e23affb549c794644c6dc177de8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 15 Oct 2019 18:20:50 -0700 Subject: [PATCH 193/327] apply proxy settings to stream, and manage HTTP clients better --- .../com/launchdarkly/client/Components.java | 2 +- .../client/DefaultEventProcessor.java | 21 +- .../client/DefaultFeatureRequestor.java | 103 ++++++++++ .../launchdarkly/client/FeatureRequestor.java | 77 +------- .../com/launchdarkly/client/LDClient.java | 12 -- .../com/launchdarkly/client/LDConfig.java | 52 ++--- .../launchdarkly/client/PollingProcessor.java | 3 +- .../launchdarkly/client/StreamProcessor.java | 10 +- .../java/com/launchdarkly/client/Util.java | 40 ++++ .../client/FeatureRequestorTest.java | 180 ++++++++++-------- .../com/launchdarkly/client/LDConfigTest.java | 28 +-- .../client/PollingProcessorTest.java | 103 +++++----- .../client/StreamProcessorTest.java | 29 ++- .../com/launchdarkly/client/UtilTest.java | 58 ++++++ 14 files changed, 434 insertions(+), 284 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index ac017b7a0..65c993869 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -118,7 +118,7 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea logger.info("Starting LaunchDarkly in LDD mode. Skipping direct feature retrieval."); return new UpdateProcessor.NullUpdateProcessor(); } else { - FeatureRequestor requestor = new FeatureRequestor(sdkKey, config); + DefaultFeatureRequestor requestor = new DefaultFeatureRequestor(sdkKey, config); if (config.stream) { logger.info("Enabling streaming API"); return new StreamProcessor(sdkKey, config, requestor, featureStore, null); diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 30fc0bdfb..4e8c27df1 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -25,11 +25,14 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import static com.launchdarkly.client.Util.configureHttpClientBuilder; import static com.launchdarkly.client.Util.getRequestBuilder; import static com.launchdarkly.client.Util.httpErrorMessage; import static com.launchdarkly.client.Util.isHttpErrorRecoverable; +import static com.launchdarkly.client.Util.shutdownHttpClient; import okhttp3.MediaType; +import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; @@ -46,7 +49,7 @@ final class DefaultEventProcessor implements EventProcessor { DefaultEventProcessor(String sdkKey, LDConfig config) { inbox = new ArrayBlockingQueue<>(config.capacity); - + ThreadFactory threadFactory = new ThreadFactoryBuilder() .setDaemon(true) .setNameFormat("LaunchDarkly-EventProcessor-%d") @@ -181,6 +184,7 @@ static final class EventDispatcher { private static final int MESSAGE_BATCH_SIZE = 50; private final LDConfig config; + private final OkHttpClient httpClient; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; private final Random random = new Random(); @@ -194,6 +198,10 @@ private EventDispatcher(String sdkKey, LDConfig config, this.config = config; this.busyFlushWorkersCount = new AtomicInteger(0); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config, httpBuilder); + httpClient = httpBuilder.build(); + // This queue only holds one element; it represents a flush task that has not yet been // picked up by any worker, so if we try to push another one and are refused, it means // all the workers are busy. @@ -236,7 +244,7 @@ public void handleResponse(Response response, Date responseDate) { } }; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { - SendEventsTask task = new SendEventsTask(sdkKey, config, listener, payloadQueue, + SendEventsTask task = new SendEventsTask(sdkKey, config, httpClient, listener, payloadQueue, busyFlushWorkersCount, threadFactory); flushWorkers.add(task); } @@ -291,8 +299,7 @@ private void doShutdown() { for (SendEventsTask task: flushWorkers) { task.stop(); } - // Note that we don't close the HTTP client here, because it's shared by other components - // via the LDConfig. The LDClient will dispose of it. + shutdownHttpClient(httpClient); } private void waitUntilAllFlushWorkersInactive() { @@ -477,6 +484,7 @@ private static interface EventResponseListener { private static final class SendEventsTask implements Runnable { private final String sdkKey; private final LDConfig config; + private final OkHttpClient httpClient; private final EventResponseListener responseListener; private final BlockingQueue payloadQueue; private final AtomicInteger activeFlushWorkersCount; @@ -485,11 +493,12 @@ private static final class SendEventsTask implements Runnable { private final Thread thread; private final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); // need one instance per task because the date parser isn't thread-safe - SendEventsTask(String sdkKey, LDConfig config, EventResponseListener responseListener, + SendEventsTask(String sdkKey, LDConfig config, OkHttpClient httpClient, EventResponseListener responseListener, BlockingQueue payloadQueue, AtomicInteger activeFlushWorkersCount, ThreadFactory threadFactory) { this.sdkKey = sdkKey; this.config = config; + this.httpClient = httpClient; this.formatter = new EventOutput.Formatter(config.inlineUsersInEvents); this.responseListener = responseListener; this.payloadQueue = payloadQueue; @@ -551,7 +560,7 @@ private void postEvents(List eventsOut) { .build(); long startTime = System.currentTimeMillis(); - try (Response response = config.httpClient.newCall(request).execute()) { + try (Response response = httpClient.newCall(request).execute()) { long endTime = System.currentTimeMillis(); logger.debug("Event delivery took {} ms, response status {}", endTime - startTime, response.code()); if (!response.isSuccessful()) { diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java new file mode 100644 index 000000000..99bbd9535 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -0,0 +1,103 @@ +package com.launchdarkly.client; + +import com.google.common.io.Files; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static com.launchdarkly.client.Util.configureHttpClientBuilder; +import static com.launchdarkly.client.Util.getRequestBuilder; +import static com.launchdarkly.client.Util.shutdownHttpClient; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; + +import okhttp3.Cache; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +class DefaultFeatureRequestor implements FeatureRequestor { + private static final Logger logger = LoggerFactory.getLogger(DefaultFeatureRequestor.class); + private static final String GET_LATEST_FLAGS_PATH = "/sdk/latest-flags"; + private static final String GET_LATEST_SEGMENTS_PATH = "/sdk/latest-segments"; + private static final String GET_LATEST_ALL_PATH = "/sdk/latest-all"; + private static final long MAX_HTTP_CACHE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB + + private final String sdkKey; + private final LDConfig config; + private final OkHttpClient httpClient; + + DefaultFeatureRequestor(String sdkKey, LDConfig config) { + this.sdkKey = sdkKey; + this.config = config; + + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config, httpBuilder); + + // HTTP caching is used only for FeatureRequestor. However, when streaming is enabled, HTTP GETs + // made by FeatureRequester will always guarantee a new flag state, so we disable the cache. + if (!config.stream) { + File cacheDir = Files.createTempDir(); + Cache cache = new Cache(cacheDir, MAX_HTTP_CACHE_SIZE_BYTES); + httpBuilder.cache(cache); + } + + httpClient = httpBuilder.build(); + } + + public void close() { + shutdownHttpClient(httpClient); + } + + public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); + return config.gson.fromJson(body, FeatureFlag.class); + } + + public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); + return config.gson.fromJson(body, Segment.class); + } + + public AllData getAllData() throws IOException, HttpErrorException { + String body = get(GET_LATEST_ALL_PATH); + return config.gson.fromJson(body, AllData.class); + } + + static Map, Map> toVersionedDataMap(AllData allData) { + Map, Map> ret = new HashMap<>(); + ret.put(FEATURES, allData.flags); + ret.put(SEGMENTS, allData.segments); + return ret; + } + + private String get(String path) throws IOException, HttpErrorException { + Request request = getRequestBuilder(sdkKey) + .url(config.baseURI.resolve(path).toURL()) + .get() + .build(); + + logger.debug("Making request: " + request); + + try (Response response = httpClient.newCall(request).execute()) { + String body = response.body().string(); + + if (!response.isSuccessful()) { + throw new HttpErrorException(response.code()); + } + logger.debug("Get flag(s) response: " + response.toString() + " with body: " + body); + logger.debug("Network response: " + response.networkResponse()); + if(!config.stream) { + logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); + logger.debug("Cache response: " + response.cacheResponse()); + } + + return body; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/FeatureRequestor.java b/src/main/java/com/launchdarkly/client/FeatureRequestor.java index f4a031a5e..9d8e7062c 100644 --- a/src/main/java/com/launchdarkly/client/FeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/FeatureRequestor.java @@ -1,26 +1,15 @@ package com.launchdarkly.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.io.Closeable; import java.io.IOException; -import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.Util.getRequestBuilder; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +interface FeatureRequestor extends Closeable { + FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException; -import okhttp3.Request; -import okhttp3.Response; + Segment getSegment(String segmentKey) throws IOException, HttpErrorException; -class FeatureRequestor { - private static final Logger logger = LoggerFactory.getLogger(FeatureRequestor.class); - private static final String GET_LATEST_FLAGS_PATH = "/sdk/latest-flags"; - private static final String GET_LATEST_SEGMENTS_PATH = "/sdk/latest-segments"; - private static final String GET_LATEST_ALL_PATH = "/sdk/latest-all"; - private final String sdkKey; - private final LDConfig config; + AllData getAllData() throws IOException, HttpErrorException; static class AllData { final Map flags; @@ -30,57 +19,5 @@ static class AllData { this.flags = flags; this.segments = segments; } - } - - FeatureRequestor(String sdkKey, LDConfig config) { - this.sdkKey = sdkKey; - this.config = config; - } - - FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { - String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return config.gson.fromJson(body, FeatureFlag.class); - } - - Segment getSegment(String segmentKey) throws IOException, HttpErrorException { - String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return config.gson.fromJson(body, Segment.class); - } - - AllData getAllData() throws IOException, HttpErrorException { - String body = get(GET_LATEST_ALL_PATH); - return config.gson.fromJson(body, AllData.class); - } - - static Map, Map> toVersionedDataMap(AllData allData) { - Map, Map> ret = new HashMap<>(); - ret.put(FEATURES, allData.flags); - ret.put(SEGMENTS, allData.segments); - return ret; - } - - private String get(String path) throws IOException, HttpErrorException { - Request request = getRequestBuilder(sdkKey) - .url(config.baseURI.resolve(path).toURL()) - .get() - .build(); - - logger.debug("Making request: " + request); - - try (Response response = config.httpClient.newCall(request).execute()) { - String body = response.body().string(); - - if (!response.isSuccessful()) { - throw new HttpErrorException(response.code()); - } - logger.debug("Get flag(s) response: " + response.toString() + " with body: " + body); - logger.debug("Network response: " + response.networkResponse()); - if(!config.stream) { - logger.debug("Cache hit count: " + config.httpClient.cache().hitCount() + " Cache network Count: " + config.httpClient.cache().networkCount()); - logger.debug("Cache response: " + response.cacheResponse()); - } - - return body; - } - } -} \ 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 defaacc73..2093c7934 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -357,18 +357,6 @@ public void close() throws IOException { } this.eventProcessor.close(); this.updateProcessor.close(); - if (this.config.httpClient != null) { - if (this.config.httpClient.dispatcher() != null && this.config.httpClient.dispatcher().executorService() != null) { - this.config.httpClient.dispatcher().cancelAll(); - this.config.httpClient.dispatcher().executorService().shutdownNow(); - } - if (this.config.httpClient.connectionPool() != null) { - this.config.httpClient.connectionPool().evictAll(); - } - if (this.config.httpClient.cache() != null) { - this.config.httpClient.cache().close(); - } - } } @Override diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 05683e8ff..38ff7b475 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -1,20 +1,11 @@ package com.launchdarkly.client; -import com.google.common.io.Files; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import okhttp3.Authenticator; -import okhttp3.Cache; -import okhttp3.ConnectionPool; -import okhttp3.Credentials; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.Route; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.File; import java.io.IOException; import java.net.InetSocketAddress; import java.net.Proxy; @@ -23,9 +14,16 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.TimeUnit; + import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; +import okhttp3.Authenticator; +import okhttp3.Credentials; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Route; + /** * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.client.LDConfig.Builder}. */ @@ -46,7 +44,6 @@ public final class LDConfig { private static final int DEFAULT_USER_KEYS_CAPACITY = 1000; private static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; private static final long DEFAULT_RECONNECT_TIME_MILLIS = 1000; - private static final long MAX_HTTP_CACHE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB protected static final LDConfig DEFAULT = new Builder().build(); @@ -57,7 +54,6 @@ public final class LDConfig { final int flushInterval; final Proxy proxy; final Authenticator proxyAuthenticator; - final OkHttpClient httpClient; final boolean stream; final FeatureStore deprecatedFeatureStore; final FeatureStoreFactory featureStoreFactory; @@ -77,6 +73,10 @@ public final class LDConfig { final boolean inlineUsersInEvents; final SSLSocketFactory sslSocketFactory; final X509TrustManager trustManager; + final int connectTimeout; + final TimeUnit connectTimeoutUnit; + final int socketTimeout; + final TimeUnit socketTimeoutUnit; protected LDConfig(Builder builder) { this.baseURI = builder.baseURI; @@ -109,38 +109,18 @@ protected LDConfig(Builder builder) { this.inlineUsersInEvents = builder.inlineUsersInEvents; this.sslSocketFactory = builder.sslSocketFactory; this.trustManager = builder.trustManager; - - OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder() - .connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) - .connectTimeout(builder.connectTimeout, builder.connectTimeoutUnit) - .readTimeout(builder.socketTimeout, builder.socketTimeoutUnit) - .writeTimeout(builder.socketTimeout, builder.socketTimeoutUnit) - .retryOnConnectionFailure(false); // we will implement our own retry logic - - if (sslSocketFactory != null) { - httpClientBuilder.sslSocketFactory(sslSocketFactory, trustManager); - } - - // When streaming is enabled, http GETs made by FeatureRequester will - // always guarantee a new flag state. So, disable http response caching - // when streaming. - if(!this.stream) { - File cacheDir = Files.createTempDir(); - Cache cache = new Cache(cacheDir, MAX_HTTP_CACHE_SIZE_BYTES); - httpClientBuilder.cache(cache); - } + this.connectTimeout = builder.connectTimeout; + this.connectTimeoutUnit = builder.connectTimeoutUnit; + this.socketTimeout = builder.socketTimeout; + this.socketTimeoutUnit = builder.socketTimeoutUnit; if (proxy != null) { - httpClientBuilder.proxy(proxy); if (proxyAuthenticator != null) { - httpClientBuilder.proxyAuthenticator(proxyAuthenticator); logger.info("Using proxy: " + proxy + " with authentication."); } else { logger.info("Using proxy: " + proxy + " without authentication."); } } - - this.httpClient = httpClientBuilder.build(); } /** diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index ad8fdaead..436fa4659 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -41,6 +41,7 @@ public boolean initialized() { public void close() throws IOException { logger.info("Closing LaunchDarkly PollingProcessor"); scheduler.shutdown(); + requestor.close(); } @Override @@ -58,7 +59,7 @@ public Future start() { public void run() { try { FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(FeatureRequestor.toVersionedDataMap(allData)); + store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); if (!initialized.getAndSet(true)) { logger.info("Initialized LaunchDarkly client."); initFuture.set(null); diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 2ac867af8..ce76e09ac 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -17,6 +17,7 @@ import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; +import static com.launchdarkly.client.Util.configureHttpClientBuilder; import static com.launchdarkly.client.Util.httpErrorMessage; import static com.launchdarkly.client.Util.isHttpErrorRecoverable; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -110,7 +111,7 @@ public void onMessage(String name, MessageEvent event) throws Exception { switch (name) { case PUT: { PutData putData = gson.fromJson(event.getData(), PutData.class); - store.init(FeatureRequestor.toVersionedDataMap(putData.data)); + store.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); @@ -142,7 +143,7 @@ public void onMessage(String name, MessageEvent event) throws Exception { case INDIRECT_PUT: try { FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(FeatureRequestor.toVersionedDataMap(allData)); + store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); @@ -206,6 +207,7 @@ public void close() throws IOException { if (store != null) { store.close(); } + requestor.close(); } @Override @@ -244,9 +246,7 @@ public EventSource createEventSource(final LDConfig config, EventHandler handler EventSource.Builder builder = new EventSource.Builder(handler, streamUri) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { - if (config.sslSocketFactory != null) { - builder.sslSocketFactory(config.sslSocketFactory, config.trustManager); - } + configureHttpClientBuilder(config, builder); } }) .connectionErrorHandler(errorHandler) diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 78ea7b759..8cf6acb33 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -4,6 +4,10 @@ import org.joda.time.DateTime; import org.joda.time.DateTimeZone; +import java.util.concurrent.TimeUnit; + +import okhttp3.ConnectionPool; +import okhttp3.OkHttpClient; import okhttp3.Request; class Util { @@ -27,12 +31,48 @@ static DateTime jsonPrimitiveToDateTime(JsonPrimitive maybeDate) { } } + static void configureHttpClientBuilder(LDConfig config, OkHttpClient.Builder builder) { + builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) + .connectTimeout(config.connectTimeout, config.connectTimeoutUnit) + .readTimeout(config.socketTimeout, config.socketTimeoutUnit) + .writeTimeout(config.socketTimeout, config.socketTimeoutUnit) + .retryOnConnectionFailure(false); // we will implement our own retry logic + + if (config.sslSocketFactory != null) { + builder.sslSocketFactory(config.sslSocketFactory, config.trustManager); + } + + if (config.proxy != null) { + builder.proxy(config.proxy); + if (config.proxyAuthenticator != null) { + builder.proxyAuthenticator(config.proxyAuthenticator); + } + } + } + static Request.Builder getRequestBuilder(String sdkKey) { return new Request.Builder() .addHeader("Authorization", sdkKey) .addHeader("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION); } + static void shutdownHttpClient(OkHttpClient client) { + if (client.dispatcher() != null) { + client.dispatcher().cancelAll(); + if (client.dispatcher().executorService() != null) { + client.dispatcher().executorService().shutdown(); + } + } + if (client.connectionPool() != null) { + client.connectionPool().evictAll(); + } + if (client.cache() != null) { + try { + client.cache().close(); + } catch (Exception e) {} + } + } + /** * Tests whether an HTTP error status represents a condition that might resolve on its own if we retry. * @param statusCode the HTTP status diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index c3de17556..b80386f10 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -3,6 +3,7 @@ import org.junit.Assert; import org.junit.Test; +import java.net.URI; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLHandshakeException; @@ -10,15 +11,18 @@ import static com.launchdarkly.client.TestHttpUtil.baseConfig; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.jsonResponse; +import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.fail; +import okhttp3.HttpUrl; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; +@SuppressWarnings("javadoc") public class FeatureRequestorTest { private static final String sdkKey = "sdk-key"; private static final String flag1Key = "flag1"; @@ -33,22 +37,22 @@ public class FeatureRequestorTest { public void requestAllData() throws Exception { MockResponse resp = jsonResponse(allDataJson); - try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { - FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); - - FeatureRequestor.AllData data = r.getAllData(); - - RecordedRequest req = server.takeRequest(); - assertEquals("/sdk/latest-all", req.getPath()); - verifyHeaders(req); - - assertNotNull(data); - assertNotNull(data.flags); - assertNotNull(data.segments); - assertEquals(1, data.flags.size()); - assertEquals(1, data.flags.size()); - verifyFlag(data.flags.get(flag1Key), flag1Key); - verifySegment(data.segments.get(segment1Key), segment1Key); + try (MockWebServer server = makeStartedServer(resp)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + FeatureRequestor.AllData data = r.getAllData(); + + RecordedRequest req = server.takeRequest(); + assertEquals("/sdk/latest-all", req.getPath()); + verifyHeaders(req); + + assertNotNull(data); + assertNotNull(data.flags); + assertNotNull(data.segments); + assertEquals(1, data.flags.size()); + assertEquals(1, data.flags.size()); + verifyFlag(data.flags.get(flag1Key), flag1Key); + verifySegment(data.segments.get(segment1Key), segment1Key); + } } } @@ -56,16 +60,16 @@ public void requestAllData() throws Exception { public void requestFlag() throws Exception { MockResponse resp = jsonResponse(flag1Json); - try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { - FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); - - FeatureFlag flag = r.getFlag(flag1Key); - - RecordedRequest req = server.takeRequest(); - assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); - verifyHeaders(req); - - verifyFlag(flag, flag1Key); + try (MockWebServer server = makeStartedServer(resp)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + FeatureFlag flag = r.getFlag(flag1Key); + + RecordedRequest req = server.takeRequest(); + assertEquals("/sdk/latest-flags/" + flag1Key, req.getPath()); + verifyHeaders(req); + + verifyFlag(flag, flag1Key); + } } } @@ -73,16 +77,16 @@ public void requestFlag() throws Exception { public void requestSegment() throws Exception { MockResponse resp = jsonResponse(segment1Json); - try (MockWebServer server = TestHttpUtil.makeStartedServer(resp)) { - FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); - - Segment segment = r.getSegment(segment1Key); - - RecordedRequest req = server.takeRequest(); - assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); - verifyHeaders(req); - - verifySegment(segment, segment1Key); + try (MockWebServer server = makeStartedServer(resp)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + Segment segment = r.getSegment(segment1Key); + + RecordedRequest req = server.takeRequest(); + assertEquals("/sdk/latest-segments/" + segment1Key, req.getPath()); + verifyHeaders(req); + + verifySegment(segment, segment1Key); + } } } @@ -90,14 +94,14 @@ public void requestSegment() throws Exception { public void requestFlagNotFound() throws Exception { MockResponse notFoundResp = new MockResponse().setResponseCode(404); - try (MockWebServer server = TestHttpUtil.makeStartedServer(notFoundResp)) { - FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); - - try { - r.getFlag(flag1Key); - Assert.fail("expected exception"); - } catch (HttpErrorException e) { - assertEquals(404, e.getStatus()); + try (MockWebServer server = makeStartedServer(notFoundResp)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try { + r.getFlag(flag1Key); + Assert.fail("expected exception"); + } catch (HttpErrorException e) { + assertEquals(404, e.getStatus()); + } } } } @@ -106,14 +110,14 @@ public void requestFlagNotFound() throws Exception { public void requestSegmentNotFound() throws Exception { MockResponse notFoundResp = new MockResponse().setResponseCode(404); - try (MockWebServer server = TestHttpUtil.makeStartedServer(notFoundResp)) { - FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); - - try { - r.getSegment(segment1Key); - fail("expected exception"); - } catch (HttpErrorException e) { - assertEquals(404, e.getStatus()); + try (MockWebServer server = makeStartedServer(notFoundResp)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try { + r.getSegment(segment1Key); + fail("expected exception"); + } catch (HttpErrorException e) { + assertEquals(404, e.getStatus()); + } } } } @@ -124,20 +128,20 @@ public void requestsAreCached() throws Exception { .setHeader("ETag", "aaa") .setHeader("Cache-Control", "max-age=1000"); - try (MockWebServer server = TestHttpUtil.makeStartedServer(cacheableResp)) { - FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(server).build()); - - FeatureFlag flag1a = r.getFlag(flag1Key); - - RecordedRequest req1 = server.takeRequest(); - assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); - verifyHeaders(req1); - - verifyFlag(flag1a, flag1Key); - - FeatureFlag flag1b = r.getFlag(flag1Key); - verifyFlag(flag1b, flag1Key); - assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit + try (MockWebServer server = makeStartedServer(cacheableResp)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + FeatureFlag flag1a = r.getFlag(flag1Key); + + RecordedRequest req1 = server.takeRequest(); + assertEquals("/sdk/latest-flags/" + flag1Key, req1.getPath()); + verifyHeaders(req1); + + verifyFlag(flag1a, flag1Key); + + FeatureFlag flag1b = r.getFlag(flag1Key); + verifyFlag(flag1b, flag1Key); + assertNull(server.takeRequest(0, TimeUnit.SECONDS)); // there was no second request, due to the cache hit + } } } @@ -146,15 +150,15 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { MockResponse resp = jsonResponse(flag1Json); try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { - FeatureRequestor r = new FeatureRequestor(sdkKey, basePollingConfig(serverWithCert.server).build()); - - try { - r.getFlag(flag1Key); - fail("expected exception"); - } catch (SSLHandshakeException e) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(serverWithCert.server).build())) { + try { + r.getFlag(flag1Key); + fail("expected exception"); + } catch (SSLHandshakeException e) { + } + + assertEquals(0, serverWithCert.server.getRequestCount()); } - - assertEquals(0, serverWithCert.server.getRequestCount()); } } @@ -167,10 +171,30 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); - FeatureRequestor r = new FeatureRequestor(sdkKey, config); - - FeatureFlag flag = r.getFlag(flag1Key); - verifyFlag(flag, flag1Key); + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { + FeatureFlag flag = r.getFlag(flag1Key); + verifyFlag(flag, flag1Key); + } + } + } + + @Test + public void httpClientCanUseProxyConfig() throws Exception { + URI fakeBaseUri = URI.create("http://not-a-real-host"); + try (MockWebServer server = makeStartedServer(jsonResponse(flag1Json))) { + HttpUrl serverUrl = server.url("/"); + LDConfig config = new LDConfig.Builder() + .baseURI(fakeBaseUri) + .proxyHost(serverUrl.host()) + .proxyPort(serverUrl.port()) + .build(); + + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { + FeatureFlag flag = r.getFlag(flag1Key); + verifyFlag(flag, flag1Key); + + assertEquals(1, server.getRequestCount()); + } } } diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index e6f800b65..f7bfb689a 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -9,34 +9,8 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +@SuppressWarnings("javadoc") public class LDConfigTest { - @Test - public void testConnectTimeoutSpecifiedInSeconds() { - LDConfig config = new LDConfig.Builder().connectTimeout(3).build(); - - assertEquals(3000, config.httpClient.connectTimeoutMillis()); - } - - @Test - public void testConnectTimeoutSpecifiedInMilliseconds() { - LDConfig config = new LDConfig.Builder().connectTimeoutMillis(3000).build(); - - assertEquals(3000, config.httpClient.connectTimeoutMillis()); - } - @Test - public void testSocketTimeoutSpecifiedInSeconds() { - LDConfig config = new LDConfig.Builder().socketTimeout(3).build(); - - assertEquals(3000, config.httpClient.readTimeoutMillis()); - } - - @Test - public void testSocketTimeoutSpecifiedInMilliseconds() { - LDConfig config = new LDConfig.Builder().socketTimeoutMillis(3000).build(); - - assertEquals(3000, config.httpClient.readTimeoutMillis()); - } - @Test public void testNoProxyConfigured() { LDConfig config = new LDConfig.Builder().build(); diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index dc18f1421..b143cc35f 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.client; -import org.easymock.EasyMockSupport; import org.junit.Test; import java.io.IOException; @@ -9,49 +8,43 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import static org.easymock.EasyMock.expect; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -public class PollingProcessorTest extends EasyMockSupport { +@SuppressWarnings("javadoc") +public class PollingProcessorTest { @Test public void testConnectionOk() throws Exception { - FeatureRequestor requestor = createStrictMock(FeatureRequestor.class); - PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, new InMemoryFeatureStore()); - - expect(requestor.getAllData()) - .andReturn(new FeatureRequestor.AllData(new HashMap(), new HashMap())) - .once(); - replayAll(); - - Future initFuture = pollingProcessor.start(); - initFuture.get(1000, TimeUnit.MILLISECONDS); - assertTrue(pollingProcessor.initialized()); - pollingProcessor.close(); - verifyAll(); + MockFeatureRequestor requestor = new MockFeatureRequestor(); + requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); + FeatureStore store = new InMemoryFeatureStore(); + + try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { + Future initFuture = pollingProcessor.start(); + initFuture.get(1000, TimeUnit.MILLISECONDS); + assertTrue(pollingProcessor.initialized()); + assertTrue(store.initialized()); + } } @Test public void testConnectionProblem() throws Exception { - FeatureRequestor requestor = createStrictMock(FeatureRequestor.class); - PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, new InMemoryFeatureStore()); - - expect(requestor.getAllData()) - .andThrow(new IOException("This exception is part of a test and yes you should be seeing it.")) - .once(); - replayAll(); + MockFeatureRequestor requestor = new MockFeatureRequestor(); + requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); + FeatureStore store = new InMemoryFeatureStore(); - Future initFuture = pollingProcessor.start(); - try { - initFuture.get(200L, TimeUnit.MILLISECONDS); - fail("Expected Timeout, instead initFuture.get() returned."); - } catch (TimeoutException ignored) { + try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { + Future initFuture = pollingProcessor.start(); + try { + initFuture.get(200L, TimeUnit.MILLISECONDS); + fail("Expected Timeout, instead initFuture.get() returned."); + } catch (TimeoutException ignored) { + } + assertFalse(initFuture.isDone()); + assertFalse(pollingProcessor.initialized()); + assertFalse(store.initialized()); } - assertFalse(initFuture.isDone()); - assertFalse(pollingProcessor.initialized()); - pollingProcessor.close(); - verifyAll(); } @Test @@ -85,13 +78,9 @@ public void http500ErrorIsRecoverable() throws Exception { } private void testUnrecoverableHttpError(int status) throws Exception { - FeatureRequestor requestor = createStrictMock(FeatureRequestor.class); + MockFeatureRequestor requestor = new MockFeatureRequestor(); + requestor.httpException = new HttpErrorException(status); try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, new InMemoryFeatureStore())) { - expect(requestor.getAllData()) - .andThrow(new HttpErrorException(status)) - .once(); - replayAll(); - long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); try { @@ -102,18 +91,13 @@ private void testUnrecoverableHttpError(int status) throws Exception { assertTrue((System.currentTimeMillis() - startTime) < 9000); assertTrue(initFuture.isDone()); assertFalse(pollingProcessor.initialized()); - verifyAll(); } } private void testRecoverableHttpError(int status) throws Exception { - FeatureRequestor requestor = createStrictMock(FeatureRequestor.class); + MockFeatureRequestor requestor = new MockFeatureRequestor(); + requestor.httpException = new HttpErrorException(status); try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, new InMemoryFeatureStore())) { - expect(requestor.getAllData()) - .andThrow(new HttpErrorException(status)) - .once(); - replayAll(); - Future initFuture = pollingProcessor.start(); try { initFuture.get(200, TimeUnit.MILLISECONDS); @@ -122,7 +106,32 @@ private void testRecoverableHttpError(int status) throws Exception { } assertFalse(initFuture.isDone()); assertFalse(pollingProcessor.initialized()); - verifyAll(); } } -} \ No newline at end of file + + private static class MockFeatureRequestor implements FeatureRequestor { + AllData allData; + HttpErrorException httpException; + IOException ioException; + + public void close() throws IOException {} + + public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + return null; + } + + public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { + return null; + } + + public AllData getAllData() throws IOException, HttpErrorException { + if (httpException != null) { + throw httpException; + } + if (ioException != null) { + throw ioException; + } + return allData; + } + } +} diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index d3fa4270e..92a45136b 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -22,6 +22,7 @@ import javax.net.ssl.SSLHandshakeException; import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; +import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; @@ -34,8 +35,10 @@ import static org.junit.Assert.fail; import okhttp3.Headers; -import okhttp3.mockwebserver.MockResponse; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockWebServer; +@SuppressWarnings("javadoc") public class StreamProcessorTest extends EasyMockSupport { private static final String SDK_KEY = "sdk_key"; @@ -371,6 +374,30 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { } } } + + @Test + public void httpClientCanUseProxyConfig() throws Exception { + final ConnectionErrorSink errorSink = new ConnectionErrorSink(); + URI fakeStreamUri = URI.create("http://not-a-real-host"); + try (MockWebServer server = makeStartedServer(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA))) { + HttpUrl serverUrl = server.url("/"); + LDConfig config = new LDConfig.Builder() + .streamURI(fakeStreamUri) + .proxyHost(serverUrl.host()) + .proxyPort(serverUrl.port()) + .build(); + + try (StreamProcessor sp = new StreamProcessor("sdk-key", config, + mockRequestor, featureStore, null)) { + sp.connectionErrorHandler = errorSink; + Future ready = sp.start(); + ready.get(); + + assertNull(errorSink.errors.peek()); + assertEquals(1, server.getRequestCount()); + } + } + } static class ConnectionErrorSink implements ConnectionErrorHandler { final BlockingQueue errors = new LinkedBlockingQueue<>(); diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java index 3be37ba6a..760632be4 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -6,6 +6,12 @@ import org.junit.Assert; import org.junit.Test; +import static com.launchdarkly.client.Util.configureHttpClientBuilder; +import static com.launchdarkly.client.Util.shutdownHttpClient; +import static org.junit.Assert.assertEquals; + +import okhttp3.OkHttpClient; + public class UtilTest { @Test public void testDateTimeConversionWithTimeZone() { @@ -72,4 +78,56 @@ public void testDateTimeConversionInvalidString() { DateTime actual = Util.jsonPrimitiveToDateTime(new JsonPrimitive(invalidTimestamp)); Assert.assertNull(actual); } + + @Test + public void testConnectTimeoutSpecifiedInSeconds() { + LDConfig config = new LDConfig.Builder().connectTimeout(3).build(); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config, httpBuilder); + OkHttpClient httpClient = httpBuilder.build(); + try { + assertEquals(3000, httpClient.connectTimeoutMillis()); + } finally { + shutdownHttpClient(httpClient); + } + } + + @Test + public void testConnectTimeoutSpecifiedInMilliseconds() { + LDConfig config = new LDConfig.Builder().connectTimeoutMillis(3000).build(); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config, httpBuilder); + OkHttpClient httpClient = httpBuilder.build(); + try { + assertEquals(3000, httpClient.connectTimeoutMillis()); + } finally { + shutdownHttpClient(httpClient); + } + } + + @Test + public void testSocketTimeoutSpecifiedInSeconds() { + LDConfig config = new LDConfig.Builder().socketTimeout(3).build(); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config, httpBuilder); + OkHttpClient httpClient = httpBuilder.build(); + try { + assertEquals(3000, httpClient.readTimeoutMillis()); + } finally { + shutdownHttpClient(httpClient); + } + } + + @Test + public void testSocketTimeoutSpecifiedInMilliseconds() { + LDConfig config = new LDConfig.Builder().socketTimeoutMillis(3000).build(); + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(config, httpBuilder); + OkHttpClient httpClient = httpBuilder.build(); + try { + assertEquals(3000, httpClient.readTimeoutMillis()); + } finally { + shutdownHttpClient(httpClient); + } + } } From 69beb6783b0c42eee96a7a43d29f724a38ea2f22 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 15 Oct 2019 18:43:23 -0700 Subject: [PATCH 194/327] better exceptions for null params --- .../com/launchdarkly/client/LDClient.java | 5 ++-- .../com/launchdarkly/client/LDClientTest.java | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index defaacc73..402f8d5da 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -22,6 +22,7 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import static com.google.common.base.Preconditions.checkNotNull; import static com.launchdarkly.client.VersionedDataKind.FEATURES; /** @@ -58,8 +59,8 @@ public LDClient(String sdkKey) { * @param config a client configuration object */ public LDClient(String sdkKey, LDConfig config) { - this.config = config; - this.sdkKey = sdkKey; + this.config = checkNotNull(config, "config must not be null"); + this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); FeatureStore store; if (config.deprecatedFeatureStore != null) { diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 158977fad..5363fce89 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -41,6 +41,7 @@ /** * See also LDClientEvaluationTest, etc. This file contains mostly tests for the startup logic. */ +@SuppressWarnings("javadoc") public class LDClientTest extends EasyMockSupport { private UpdateProcessor updateProcessor; private EventProcessor eventProcessor; @@ -55,6 +56,33 @@ public void before() { initFuture = createStrictMock(Future.class); } + @Test + public void constructorThrowsExceptionForNullSdkKey() throws Exception { + try (LDClient client = new LDClient(null)) { + fail("expected exception"); + } catch (NullPointerException e) { + assertEquals("sdkKey must not be null", e.getMessage()); + } + } + + @Test + public void constructorWithConfigThrowsExceptionForNullSdkKey() throws Exception { + try (LDClient client = new LDClient(null, new LDConfig.Builder().build())) { + fail("expected exception"); + } catch (NullPointerException e) { + assertEquals("sdkKey must not be null", e.getMessage()); + } + } + + @Test + public void constructorThrowsExceptionForNullConfig() throws Exception { + try (LDClient client = new LDClient("SDK_KEY", null)) { + fail("expected exception"); + } catch (NullPointerException e) { + assertEquals("config must not be null", e.getMessage()); + } + } + @Test public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception { LDConfig config = new LDConfig.Builder() From ff6866c2bee9100b2410f85f873b6fd9b8b8a33c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 16 Oct 2019 18:54:35 -0700 Subject: [PATCH 195/327] add null guard --- src/main/java/com/launchdarkly/client/LDUser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 450fddd90..f1c241b0e 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -141,7 +141,7 @@ LDValue getAnonymous() { LDValue getCustom(String key) { if (custom != null) { - return custom.get(key); + return LDValue.normalize(custom.get(key)); } return LDValue.ofNull(); } From 442c1b13d827a8ed4d66fe0671c3854ca7e0e1cb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 16 Oct 2019 18:59:15 -0700 Subject: [PATCH 196/327] test that would have caught lack of null guard --- src/test/java/com/launchdarkly/client/LDUserTest.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index cca734229..971ece767 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -412,12 +412,21 @@ public void getValueGetsBuiltInAttributeEvenIfCustomAttrHasSameName() { } @Test - public void getValueReturnsNullIfNotFound() { + public void getValueReturnsNullForCustomAttrIfThereAreNoCustomAttrs() { LDUser user = new LDUser.Builder("key") .name("Jane") .build(); assertEquals(LDValue.ofNull(), user.getValueForEvaluation("height")); } + + @Test + public void getValueReturnsNullForCustomAttrIfThereAreCustomAttrsButNotThisOne() { + LDUser user = new LDUser.Builder("key") + .name("Jane") + .custom("eyes", "brown") + .build(); + assertEquals(LDValue.ofNull(), user.getValueForEvaluation("height")); + } @Test public void canAddCustomAttrWithListOfStrings() { From ccaf52073004ec78998fd2b22fc8ea97610bb9d8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 25 Oct 2019 11:28:27 -0700 Subject: [PATCH 197/327] don't use intermediate objects and reflection when encoding events to JSON --- .../client/DefaultEventProcessor.java | 17 +- .../com/launchdarkly/client/EventOutput.java | 207 ---------------- .../client/EventOutputFormatter.java | 225 ++++++++++++++++++ 3 files changed, 234 insertions(+), 215 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/EventOutput.java create mode 100644 src/main/java/com/launchdarkly/client/EventOutputFormatter.java diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 4e8c27df1..5aee5efa0 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -8,6 +8,7 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.io.StringWriter; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; @@ -489,7 +490,7 @@ private static final class SendEventsTask implements Runnable { private final BlockingQueue payloadQueue; private final AtomicInteger activeFlushWorkersCount; private final AtomicBoolean stopping; - private final EventOutput.Formatter formatter; + private final EventOutputFormatter formatter; private final Thread thread; private final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); // need one instance per task because the date parser isn't thread-safe @@ -499,7 +500,7 @@ private static final class SendEventsTask implements Runnable { this.sdkKey = sdkKey; this.config = config; this.httpClient = httpClient; - this.formatter = new EventOutput.Formatter(config.inlineUsersInEvents); + this.formatter = new EventOutputFormatter(config); this.responseListener = responseListener; this.payloadQueue = payloadQueue; this.activeFlushWorkersCount = activeFlushWorkersCount; @@ -518,9 +519,10 @@ public void run() { continue; } try { - List eventsOut = formatter.makeOutputEvents(payload.events, payload.summary); - if (!eventsOut.isEmpty()) { - postEvents(eventsOut); + StringWriter stringWriter = new StringWriter(); + int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summary, stringWriter); + if (outputEventCount > 0) { + postEvents(stringWriter.toString(), outputEventCount); } } catch (Exception e) { logger.error("Unexpected error in event processor: {}", e.toString()); @@ -538,12 +540,11 @@ void stop() { thread.interrupt(); } - private void postEvents(List eventsOut) { - String json = config.gson.toJson(eventsOut); + private void postEvents(String json, int outputEventCount) { String uriStr = config.eventsURI.toString() + "/bulk"; logger.debug("Posting {} event(s) to {} with payload: {}", - eventsOut.size(), uriStr, json); + outputEventCount, uriStr, json); for (int attempt = 0; attempt < 2; attempt++) { if (attempt > 0) { diff --git a/src/main/java/com/launchdarkly/client/EventOutput.java b/src/main/java/com/launchdarkly/client/EventOutput.java deleted file mode 100644 index 866ab298a..000000000 --- a/src/main/java/com/launchdarkly/client/EventOutput.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.annotations.SerializedName; -import com.launchdarkly.client.EventSummarizer.CounterKey; -import com.launchdarkly.client.EventSummarizer.CounterValue; -import com.launchdarkly.client.value.LDValue; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Base class for data structures that we send in an event payload, which are somewhat - * different in shape from the originating events. Also defines all of its own subclasses - * and the class that constructs them. These are implementation details used only by - * DefaultEventProcessor and related classes, so they are all package-private. - */ -abstract class EventOutput { - @SuppressWarnings("unused") - private final String kind; - - protected EventOutput(String kind) { - this.kind = kind; - } - - static class EventOutputWithTimestamp extends EventOutput { - @SuppressWarnings("unused") - private final long creationDate; - - protected EventOutputWithTimestamp(String kind, long creationDate) { - super(kind); - this.creationDate = creationDate; - } - } - - @SuppressWarnings("unused") - static final class FeatureRequest extends EventOutputWithTimestamp { - private final String key; - private final String userKey; - private final LDUser user; - private final Integer version; - private final Integer variation; - private final LDValue value; - @SerializedName("default") private final LDValue defaultVal; - private final String prereqOf; - private final EvaluationReason reason; - - FeatureRequest(long creationDate, String key, String userKey, LDUser user, - Integer version, Integer variation, LDValue value, LDValue defaultVal, String prereqOf, - EvaluationReason reason, boolean debug) { - super(debug ? "debug" : "feature", creationDate); - this.key = key; - this.userKey = userKey; - this.user = user; - this.variation = variation; - this.version = version; - this.value = value; - this.defaultVal = defaultVal.isNull() ? null : defaultVal; // allows Gson to omit this property - this.prereqOf = prereqOf; - this.reason = reason; - } - } - - @SuppressWarnings("unused") - static final class Identify extends EventOutputWithTimestamp { - private final LDUser user; - private final String key; - - Identify(long creationDate, LDUser user) { - super("identify", creationDate); - this.user = user; - this.key = user.getKeyAsString(); - } - } - - @SuppressWarnings("unused") - static final class Custom extends EventOutputWithTimestamp { - private final String key; - private final String userKey; - private final LDUser user; - private final LDValue data; - private final Double metricValue; - - Custom(long creationDate, String key, String userKey, LDUser user, LDValue data, Double metricValue) { - super("custom", creationDate); - this.key = key; - this.userKey = userKey; - this.user = user; - this.data = (data == null || data.isNull()) ? null : data; // allows Gson to omit this property - this.metricValue = metricValue; - } - } - - @SuppressWarnings("unused") - static final class Index extends EventOutputWithTimestamp { - private final LDUser user; - - public Index(long creationDate, LDUser user) { - super("index", creationDate); - this.user = user; - } - } - - @SuppressWarnings("unused") - static final class Summary extends EventOutput { - private final long startDate; - private final long endDate; - private final Map features; - - Summary(long startDate, long endDate, Map features) { - super("summary"); - this.startDate = startDate; - this.endDate = endDate; - this.features = features; - } - } - - static final class SummaryEventFlag { - @SerializedName("default") final LDValue defaultVal; - final List counters; - - SummaryEventFlag(LDValue defaultVal, List counters) { - this.defaultVal = defaultVal; - this.counters = counters; - } - } - - static final class SummaryEventCounter { - final Integer variation; - final LDValue value; - final Integer version; - final long count; - final Boolean unknown; - - SummaryEventCounter(Integer variation, LDValue value, Integer version, long count, Boolean unknown) { - this.variation = variation; - this.value = value; - this.version = version; - this.count = count; - this.unknown = unknown; - } - } - - static final class Formatter { - private final boolean inlineUsers; - - Formatter(boolean inlineUsers) { - this.inlineUsers = inlineUsers; - } - - List makeOutputEvents(Event[] events, EventSummarizer.EventSummary summary) { - List eventsOut = new ArrayList<>(events.length + 1); - for (Event event: events) { - eventsOut.add(createOutputEvent(event)); - } - if (!summary.isEmpty()) { - eventsOut.add(createSummaryEvent(summary)); - } - return eventsOut; - } - - private EventOutput createOutputEvent(Event e) { - String userKey = e.user == null ? null : e.user.getKeyAsString(); - if (e instanceof Event.FeatureRequest) { - Event.FeatureRequest fe = (Event.FeatureRequest)e; - boolean inlineThisUser = inlineUsers || fe.debug; - 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.reason, fe.debug); - } else if (e instanceof Event.Identify) { - return new EventOutput.Identify(e.creationDate, e.user); - } else if (e instanceof Event.Custom) { - Event.Custom ce = (Event.Custom)e; - return new EventOutput.Custom(ce.creationDate, ce.key, - inlineUsers ? null : userKey, - inlineUsers ? e.user : null, - ce.data, - ce.metricValue); - } else if (e instanceof Event.Index) { - return new EventOutput.Index(e.creationDate, e.user); - } else { - return null; - } - } - - private EventOutput createSummaryEvent(EventSummarizer.EventSummary summary) { - Map flagsOut = new HashMap<>(); - for (Map.Entry entry: summary.counters.entrySet()) { - SummaryEventFlag fsd = flagsOut.get(entry.getKey().key); - if (fsd == null) { - fsd = new SummaryEventFlag(entry.getValue().defaultVal, new ArrayList()); - flagsOut.put(entry.getKey().key, fsd); - } - SummaryEventCounter c = new SummaryEventCounter( - entry.getKey().variation, - entry.getValue().flagValue, - entry.getKey().version, - entry.getValue().count, - entry.getKey().version == null ? true : null); - fsd.counters.add(c); - } - return new EventOutput.Summary(summary.startDate, summary.endDate, flagsOut); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java new file mode 100644 index 000000000..69b63f057 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -0,0 +1,225 @@ +package com.launchdarkly.client; + +import com.google.gson.stream.JsonWriter; +import com.launchdarkly.client.EventSummarizer.CounterKey; +import com.launchdarkly.client.EventSummarizer.CounterValue; +import com.launchdarkly.client.value.LDValue; + +import java.io.IOException; +import java.io.Writer; + +/** + * Transforms analytics events and summary data into the JSON format that we send to LaunchDarkly. + * Rather than creating intermediate objects to represent this schema, we use the Gson streaming + * output API to construct JSON directly. + */ +class EventOutputFormatter { + private final LDConfig config; + + EventOutputFormatter(LDConfig config) { + this.config = config; + } + + int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { + int count = 0; + try (JsonWriter jsonWriter = new JsonWriter(writer)) { + jsonWriter.beginArray(); + for (Event event: events) { + if (writeOutputEvent(event, jsonWriter)) { + count++; + } + } + if (!summary.isEmpty()) { + writeSummaryEvent(summary, jsonWriter); + count++; + } + jsonWriter.endArray(); + } + return count; + } + + private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException { + if (event instanceof Event.FeatureRequest) { + Event.FeatureRequest fe = (Event.FeatureRequest)event; + startEvent(fe, fe.debug ? "debug" : "feature", fe.key, jw); + writeUserOrKey(fe, fe.debug, jw); + if (fe.version != null) { + jw.name("version"); + jw.value(fe.version); + } + if (fe.variation != null) { + jw.name("variation"); + jw.value(fe.variation); + } + writeLDValue("value", fe.value, jw); + writeLDValue("default", fe.defaultVal, jw); + if (fe.prereqOf != null) { + jw.name("prereqOf"); + jw.value(fe.prereqOf); + } + writeEvaluationReason("reason", fe.reason, jw); + jw.endObject(); + } else if (event instanceof Event.Identify) { + startEvent(event, "identify", event.user == null ? null : event.user.getKeyAsString(), jw); + writeUser(event.user, jw); + jw.endObject(); + } else if (event instanceof Event.Custom) { + Event.Custom ce = (Event.Custom)event; + startEvent(event, "custom", ce.key, jw); + writeUserOrKey(ce, false, jw); + writeLDValue("data", ce.data, jw); + if (ce.metricValue != null) { + jw.name("metricValue"); + jw.value(ce.metricValue); + } + jw.endObject(); + } else if (event instanceof Event.Index) { + startEvent(event, "index", null, jw); + writeUser(event.user, jw); + jw.endObject(); + } else { + return false; + } + return true; + } + + private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter jw) throws IOException { + jw.beginObject(); + + jw.name("kind"); + jw.value("summary"); + + jw.name("startDate"); + jw.value(summary.startDate); + jw.name("endDate"); + jw.value(summary.endDate); + + jw.name("features"); + jw.beginObject(); + + CounterKey[] unprocessedKeys = summary.counters.keySet().toArray(new CounterKey[summary.counters.size()]); + for (int i = 0; i < unprocessedKeys.length; i++) { + if (unprocessedKeys[i] == null) { + continue; + } + CounterKey key = unprocessedKeys[i]; + String flagKey = key.key; + CounterValue firstValue = summary.counters.get(key); + + jw.name(flagKey); + jw.beginObject(); + + writeLDValue("default", firstValue.defaultVal, jw); + + jw.name("counters"); + jw.beginArray(); + + for (int j = i; j < unprocessedKeys.length; j++) { + CounterKey keyForThisFlag = unprocessedKeys[j]; + if (keyForThisFlag == null || keyForThisFlag.key != flagKey) { + continue; + } + CounterValue value = keyForThisFlag == key ? firstValue : summary.counters.get(keyForThisFlag); + + jw.beginObject(); + + if (keyForThisFlag.variation != null) { + jw.name("variation"); + jw.value(keyForThisFlag.variation); + } + if (keyForThisFlag.version != null) { + jw.name("version"); + jw.value(keyForThisFlag.version); + } else { + jw.name("unknown"); + jw.value(true); + } + writeLDValue("value", value.flagValue, jw); + jw.name("count"); + jw.value(value.count); + + jw.endObject(); // end of this counter + } + + jw.endArray(); // end of "counters" array + + jw.endObject(); // end of this flag + } + + jw.endObject(); // end of "features" + + jw.endObject(); + } + + private void startEvent(Event event, String kind, String key, JsonWriter jw) throws IOException { + jw.beginObject(); + jw.name("kind"); + jw.value(kind); + jw.name("creationDate"); + jw.value(event.creationDate); + if (key != null) { + jw.name("key"); + jw.value(key); + } + } + + private void writeUserOrKey(Event event, boolean forceInline, JsonWriter jw) throws IOException { + LDUser user = event.user; + if (user != null) { + if (config.inlineUsersInEvents || forceInline) { + writeUser(user, jw); + } else { + jw.name("userKey"); + jw.value(user.getKeyAsString()); + } + } + } + + private void writeUser(LDUser user, JsonWriter jw) throws IOException { + jw.name("user"); + // config.gson is already set up to use our custom serializer, which knows about private attributes + // and already uses the streaming approach + config.gson.toJson(user, LDUser.class, jw); + } + + private void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { + if (value == null || value.isNull()) { + return; + } + jw.name(key); + config.gson.toJson(value, LDValue.class, jw); // LDValue defines its own custom serializer + } + + // This logic is so that we don't have to define multiple custom serializers for the various reason subclasses. + private void writeEvaluationReason(String key, EvaluationReason er, JsonWriter jw) throws IOException { + if (er == null) { + return; + } + jw.name(key); + + jw.beginObject(); + + jw.name("kind"); + jw.value(er.getKind().name()); + + if (er instanceof EvaluationReason.Error) { + EvaluationReason.Error ere = (EvaluationReason.Error)er; + jw.name("errorKind"); + jw.value(ere.getErrorKind().name()); + } else if (er instanceof EvaluationReason.PrerequisiteFailed) { + EvaluationReason.PrerequisiteFailed erpf = (EvaluationReason.PrerequisiteFailed)er; + jw.name("prerequisiteKey"); + jw.value(erpf.getPrerequisiteKey()); + } else if (er instanceof EvaluationReason.RuleMatch) { + EvaluationReason.RuleMatch errm = (EvaluationReason.RuleMatch)er; + jw.name("ruleIndex"); + jw.value(errm.getRuleIndex()); + if (errm.getRuleId() != null) { + jw.name("ruleId"); + jw.value(errm.getRuleId()); + } + } + + jw.endObject(); + } +} From 90a66efde928174b7cfe0907f7f7d037d8814dc3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 25 Oct 2019 13:09:48 -0700 Subject: [PATCH 198/327] fix filtering of duplicate flag keys, update test to be more sensitive --- .../client/EventOutputFormatter.java | 3 +- .../client/DefaultEventProcessorTest.java | 29 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index 69b63f057..b9fb0f854 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -120,7 +120,8 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter continue; } CounterValue value = keyForThisFlag == key ? firstValue : summary.counters.get(keyForThisFlag); - + unprocessedKeys[j] = null; + jw.beginObject(); if (keyForThisFlag.variation != null) { diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 6e0a86de2..ce05c62fe 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -6,6 +6,7 @@ import com.launchdarkly.client.value.LDValue; import org.hamcrest.Matcher; +import org.hamcrest.Matchers; import org.junit.Test; import java.text.SimpleDateFormat; @@ -345,28 +346,38 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except public void nonTrackedEventsAreSummarized() throws Exception { FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).build(); - LDValue value = LDValue.of("value"); + LDValue value1 = LDValue.of("value1"); + LDValue value2 = LDValue.of("value2"); LDValue default1 = LDValue.of("default1"); LDValue default2 = LDValue.of("default2"); - Event fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, - simpleEvaluation(2, value), default1); + Event fe1a = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, + simpleEvaluation(1, value1), default1); + Event fe1b = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, + simpleEvaluation(1, value1), default1); + Event fe1c = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, + simpleEvaluation(2, value2), default1); Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, - simpleEvaluation(2, value), default2); + simpleEvaluation(2, value2), default2); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { - ep.sendEvent(fe1); + ep.sendEvent(fe1a); + ep.sendEvent(fe1b); + ep.sendEvent(fe1c); ep.sendEvent(fe2); } assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe1, userJson), + isIndexEvent(fe1a, userJson), allOf( - isSummaryEvent(fe1.creationDate, fe2.creationDate), + isSummaryEvent(fe1a.creationDate, fe2.creationDate), hasSummaryFlag(flag1.getKey(), default1, - contains(isSummaryEventCounter(flag1, 2, value, 1))), + Matchers.containsInAnyOrder( + isSummaryEventCounter(flag1, 1, value1, 2), + isSummaryEventCounter(flag1, 2, value2, 1) + )), hasSummaryFlag(flag2.getKey(), default2, - contains(isSummaryEventCounter(flag2, 2, value, 1))) + contains(isSummaryEventCounter(flag2, 2, value2, 1))) ) )); } From f875a1bbde1f1ff95f53da1080eac834f7233ee8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 Nov 2019 19:37:56 -0800 Subject: [PATCH 199/327] add convenience methods to ArrayBuilder, ObjectBuilder --- .../client/value/ArrayBuilder.java | 56 ++++++++++++++++- .../client/value/ObjectBuilder.java | 60 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java index 2dc386c69..e68b7a204 100644 --- a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java +++ b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java @@ -19,7 +19,61 @@ public ArrayBuilder add(LDValue value) { builder.add(value); return this; } - + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(boolean value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(int value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(long value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(float value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(double value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(String value) { + return add(LDValue.of(value)); + } + /** * Returns an array containing the builder's current elements. Subsequent changes to the builder * will not affect this value (it uses copy-on-write logic, so the previous values will only be diff --git a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java index 0f08a0c93..1027652d9 100644 --- a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java +++ b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java @@ -31,6 +31,66 @@ public ObjectBuilder put(String key, LDValue value) { return this; } + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, boolean value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, int value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, long value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, float value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, double value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, String value) { + return put(key, LDValue.of(value)); + } + /** * Returns an object containing the builder's current elements. Subsequent changes to the builder * will not affect this value (it uses copy-on-write logic, so the previous values will only be From f297836591bc984179af8182fc27d1c9e57aabd0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 11 Nov 2019 19:39:33 -0800 Subject: [PATCH 200/327] add tests for LDValue equality & hash code --- .../client/value/LDValueTest.java | 109 ++++++++++++++++-- 1 file changed, 99 insertions(+), 10 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index a02a94bc9..e4397a676 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -3,6 +3,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import org.junit.Test; @@ -310,18 +312,105 @@ public void nonObjectValuesBehaveLikeEmptyObject() { } } } - + + @Test + public void testEqualsAndHashCodeForPrimitives() + { + assertValueAndHashEqual(LDValue.ofNull(), LDValue.ofNull()); + assertValueAndHashEqual(LDValue.of(true), LDValue.of(true)); + assertValueAndHashNotEqual(LDValue.of(true), LDValue.of(false)); + assertValueAndHashEqual(LDValue.of(1), LDValue.of(1)); + assertValueAndHashEqual(LDValue.of(1), LDValue.of(1.0f)); + assertValueAndHashNotEqual(LDValue.of(1), LDValue.of(2)); + assertValueAndHashEqual(LDValue.of("a"), LDValue.of("a")); + assertValueAndHashNotEqual(LDValue.of("a"), LDValue.of("b")); + assertNotEquals(LDValue.of(false), LDValue.of(0)); + } + + private void assertValueAndHashEqual(LDValue a, LDValue b) + { + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + private void assertValueAndHashNotEqual(LDValue a, LDValue b) + { + assertNotEquals(a, b); + assertNotEquals(a.hashCode(), b.hashCode()); + } + @Test public void samePrimitivesWithOrWithoutJsonElementAreEqual() { - assertEquals(aTrueBoolValue, aTrueBoolValueFromJsonElement); - assertEquals(anIntValue, anIntValueFromJsonElement); - assertEquals(aLongValue, aLongValueFromJsonElement); - assertEquals(aFloatValue, aFloatValueFromJsonElement); - assertEquals(aStringValue, aStringValueFromJsonElement); - assertEquals(anArrayValue, anArrayValueFromJsonElement); - assertEquals(anObjectValue, anObjectValueFromJsonElement); + assertValueAndHashEqual(aTrueBoolValue, aTrueBoolValueFromJsonElement); + assertValueAndHashEqual(anIntValue, anIntValueFromJsonElement); + assertValueAndHashEqual(aLongValue, aLongValueFromJsonElement); + assertValueAndHashEqual(aFloatValue, aFloatValueFromJsonElement); + assertValueAndHashEqual(aStringValue, aStringValueFromJsonElement); + assertValueAndHashEqual(anArrayValue, anArrayValueFromJsonElement); + assertValueAndHashEqual(anObjectValue, anObjectValueFromJsonElement); } - + + @Test + public void equalsUsesDeepEqualityForArrays() + { + LDValue a0 = LDValue.buildArray().add("a") + .add(LDValue.buildArray().add("b").add("c").build()) + .build(); + JsonArray ja1 = new JsonArray(); + ja1.add(new JsonPrimitive("a")); + JsonArray ja1a = new JsonArray(); + ja1a.add(new JsonPrimitive("b")); + ja1a.add(new JsonPrimitive("c")); + ja1.add(ja1a); + LDValue a1 = LDValue.fromJsonElement(ja1); + assertValueAndHashEqual(a0, a1); + + LDValue a2 = LDValue.buildArray().add("a").build(); + assertValueAndHashNotEqual(a0, a2); + + LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); + assertValueAndHashNotEqual(a0, a3); + + LDValue a4 = LDValue.buildArray().add("a") + .add(LDValue.buildArray().add("b").add("x").build()) + .build(); + assertValueAndHashNotEqual(a0, a4); + } + + @Test + public void equalsUsesDeepEqualityForObjects() + { + LDValue o0 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "e").build()) + .build(); + JsonObject jo1 = new JsonObject(); + jo1.add("a", new JsonPrimitive("b")); + JsonObject jo1a = new JsonObject(); + jo1a.add("d", new JsonPrimitive("e")); + jo1.add("c", jo1a); + LDValue o1 = LDValue.fromJsonElement(jo1); + assertValueAndHashEqual(o0, o1); + + LDValue o2 = LDValue.buildObject() + .put("a", "b") + .build(); + assertValueAndHashNotEqual(o0, o2); + + LDValue o3 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "e").build()) + .put("f", "g") + .build(); + assertValueAndHashNotEqual(o0, o3); + + LDValue o4 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "f").build()) + .build(); + assertValueAndHashNotEqual(o0, o4); + } + @Test public void canUseLongTypeForNumberGreaterThanMaxInt() { long n = (long)Integer.MAX_VALUE + 1; @@ -337,7 +426,7 @@ public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { assertEquals(n, LDValue.Convert.Double.toType(LDValue.of(n)).doubleValue(), 0); assertEquals(n, LDValue.Convert.Double.fromType(n).doubleValue(), 0); } - + @Test public void testToJsonString() { assertEquals("null", LDValue.ofNull().toJsonString()); From d83ea62befa19faf31456e34fd393c7bca6b3112 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 19 Nov 2019 00:16:11 +0000 Subject: [PATCH 201/327] [ch56386] Update baseurl diagnostic fields to be booleans in Java SDK (#146) Change diagnostic configuration object for diagnostic init event to only include whether the base/events/stream urls are custom rather than actual values. Added tests for diagnostic configuration object. Removed redundant test. --- .../launchdarkly/client/DiagnosticEvent.java | 14 +- .../com/launchdarkly/client/LDConfig.java | 6 +- .../client/DiagnosticEventTest.java | 130 ++++++++++++++++++ .../client/DiagnosticStatisticsEventTest.java | 46 ------- 4 files changed, 139 insertions(+), 57 deletions(-) create mode 100644 src/test/java/com/launchdarkly/client/DiagnosticEventTest.java delete mode 100644 src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 307511d04..40c25eb50 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -1,7 +1,5 @@ package com.launchdarkly.client; -import java.net.URI; - class DiagnosticEvent { final String kind; @@ -44,9 +42,9 @@ static class Init extends DiagnosticEvent { } static class DiagnosticConfiguration { - private final URI baseURI; - private final URI eventsURI; - private final URI streamURI; + private final boolean customBaseURI; + private final boolean customEventsURI; + private final boolean customStreamURI; private final int eventsCapacity; private final int connectTimeoutMillis; private final int socketTimeoutMillis; @@ -69,9 +67,9 @@ static class DiagnosticConfiguration { private final String featureStore; DiagnosticConfiguration(LDConfig config) { - this.baseURI = config.baseURI; - this.eventsURI = config.eventsURI; - this.streamURI = config.streamURI; + this.customBaseURI = !(LDConfig.DEFAULT_BASE_URI.equals(config.baseURI)); + this.customEventsURI = !(LDConfig.DEFAULT_EVENTS_URI.equals(config.eventsURI)); + this.customStreamURI = !(LDConfig.DEFAULT_STREAM_URI.equals(config.streamURI)); this.eventsCapacity = config.capacity; this.connectTimeoutMillis = (int)config.connectTimeoutUnit.toMillis(config.connectTimeout); this.socketTimeoutMillis = (int)config.socketTimeoutUnit.toMillis(config.socketTimeout); diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 6c521d35e..18d0c4cc7 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -31,9 +31,9 @@ public final class LDConfig { private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); 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"); - private static final URI DEFAULT_STREAM_URI = URI.create("https://stream.launchdarkly.com"); + static final URI DEFAULT_BASE_URI = URI.create("https://app.launchdarkly.com"); + static final URI DEFAULT_EVENTS_URI = URI.create("https://events.launchdarkly.com"); + static final URI DEFAULT_STREAM_URI = URI.create("https://stream.launchdarkly.com"); private static final int DEFAULT_CAPACITY = 10000; private static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; private static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java new file mode 100644 index 000000000..70eb2e2cc --- /dev/null +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -0,0 +1,130 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.junit.Test; + +import java.net.URI; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +public class DiagnosticEventTest { + + private static Gson gson = new Gson(); + + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void testSerialization() { + DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3); + JsonObject jsonObject = gson.toJsonTree(diagnosticStatisticsEvent).getAsJsonObject(); + assertEquals(7, jsonObject.size()); + assertEquals("diagnostic", diagnosticStatisticsEvent.kind); + assertEquals(2000, jsonObject.getAsJsonPrimitive("creationDate").getAsLong()); + JsonObject idObject = jsonObject.getAsJsonObject("id"); + assertEquals("DK_KEY", idObject.getAsJsonPrimitive("sdkKeySuffix").getAsString()); + // Throws InvalidArgumentException on invalid UUID + UUID.fromString(idObject.getAsJsonPrimitive("diagnosticId").getAsString()); + assertEquals(1000, jsonObject.getAsJsonPrimitive("dataSinceDate").getAsLong()); + assertEquals(1, jsonObject.getAsJsonPrimitive("droppedEvents").getAsLong()); + assertEquals(2, jsonObject.getAsJsonPrimitive("deduplicatedUsers").getAsLong()); + assertEquals(3, jsonObject.getAsJsonPrimitive("eventsInQueue").getAsLong()); + } + + @Test + public void testDefaultDiagnosticConfiguration() { + LDConfig ldConfig = new LDConfig.Builder().build(); + DiagnosticEvent.Init.DiagnosticConfiguration diagnosticConfiguration = new DiagnosticEvent.Init.DiagnosticConfiguration(ldConfig); + JsonObject diagnosticJson = new Gson().toJsonTree(diagnosticConfiguration).getAsJsonObject(); + JsonObject expected = new JsonObject(); + expected.addProperty("allAttributesPrivate", false); + expected.addProperty("connectTimeoutMillis", 2_000); + expected.addProperty("customBaseURI", false); + expected.addProperty("customEventsURI", false); + expected.addProperty("customStreamURI", false); + expected.addProperty("diagnosticRecordingIntervalMillis", 900_000); + expected.addProperty("eventReportingDisabled", false); + expected.addProperty("eventsCapacity", 10_000); + expected.addProperty("eventsFlushIntervalMillis",5_000); + expected.addProperty("featureStore", "InMemoryFeatureStoreFactory"); + expected.addProperty("inlineUsersInEvents", false); + expected.addProperty("offline", false); + expected.addProperty("pollingIntervalMillis", 30_000); + expected.addProperty("reconnectTimeMillis", 1_000); + expected.addProperty("samplingInterval", 0); + expected.addProperty("socketTimeoutMillis", 10_000); + expected.addProperty("startWaitMillis", 5_000); + expected.addProperty("streamingDisabled", false); + expected.addProperty("userKeysCapacity", 1_000); + expected.addProperty("userKeysFlushIntervalMillis", 300_000); + expected.addProperty("usingProxy", false); + expected.addProperty("usingProxyAuthenticator", false); + expected.addProperty("usingRelayDaemon", false); + + assertEquals(expected, diagnosticJson); + } + + @Test + public void testCustomDiagnosticConfiguration() { + @SuppressWarnings("deprecation") + LDConfig ldConfig = new LDConfig.Builder() + .allAttributesPrivate(true) + .connectTimeout(5) + .baseURI(URI.create("https://1.1.1.1")) + .eventsURI(URI.create("https://1.1.1.1")) + .streamURI(URI.create("https://1.1.1.1")) + .diagnosticRecordingIntervalMillis(1_800_000) + .sendEvents(false) + .capacity(20_000) + .flushInterval(10) + .featureStoreFactory(Components.redisFeatureStore()) + .inlineUsersInEvents(true) + .offline(true) + .pollingIntervalMillis(60_000) + .reconnectTimeMs(2_000) + .samplingInterval(1) + .socketTimeout(20) + .startWaitMillis(10_000) + .stream(false) + .userKeysCapacity(2_000) + .userKeysFlushInterval(600) + .proxyPort(1234) + .proxyUsername("username") + .proxyPassword("password") + .useLdd(true) + .build(); + + DiagnosticEvent.Init.DiagnosticConfiguration diagnosticConfiguration = new DiagnosticEvent.Init.DiagnosticConfiguration(ldConfig); + JsonObject diagnosticJson = gson.toJsonTree(diagnosticConfiguration).getAsJsonObject(); + JsonObject expected = new JsonObject(); + expected.addProperty("allAttributesPrivate", true); + expected.addProperty("connectTimeoutMillis", 5_000); + expected.addProperty("customBaseURI", true); + expected.addProperty("customEventsURI", true); + expected.addProperty("customStreamURI", true); + expected.addProperty("diagnosticRecordingIntervalMillis", 1_800_000); + expected.addProperty("eventReportingDisabled", true); + expected.addProperty("eventsCapacity", 20_000); + expected.addProperty("eventsFlushIntervalMillis",10_000); + expected.addProperty("featureStore", "RedisFeatureStoreBuilder"); + expected.addProperty("inlineUsersInEvents", true); + expected.addProperty("offline", true); + expected.addProperty("pollingIntervalMillis", 60_000); + expected.addProperty("reconnectTimeMillis", 2_000); + expected.addProperty("samplingInterval", 1); + expected.addProperty("socketTimeoutMillis", 20_000); + expected.addProperty("startWaitMillis", 10_000); + expected.addProperty("streamingDisabled", true); + expected.addProperty("userKeysCapacity", 2_000); + expected.addProperty("userKeysFlushIntervalMillis", 600_000); + expected.addProperty("usingProxy", true); + expected.addProperty("usingProxyAuthenticator", true); + expected.addProperty("usingRelayDaemon", true); + + assertEquals(expected, diagnosticJson); + } + +} diff --git a/src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java deleted file mode 100644 index 5ec9102bd..000000000 --- a/src/test/java/com/launchdarkly/client/DiagnosticStatisticsEventTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.launchdarkly.client; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import org.junit.Test; - -import java.util.UUID; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; - -public class DiagnosticStatisticsEventTest { - - @Test - public void testConstructor() { - DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); - DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3); - assertEquals("diagnostic", diagnosticStatisticsEvent.kind); - assertEquals(2000, diagnosticStatisticsEvent.creationDate); - assertSame(diagnosticId, diagnosticStatisticsEvent.id); - assertEquals(1000, diagnosticStatisticsEvent.dataSinceDate); - assertEquals(1, diagnosticStatisticsEvent.droppedEvents); - assertEquals(2, diagnosticStatisticsEvent.deduplicatedUsers); - assertEquals(3, diagnosticStatisticsEvent.eventsInQueue); - } - - @Test - public void testSerialization() { - DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); - DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3); - Gson gson = new Gson(); - JsonObject jsonObject = gson.toJsonTree(diagnosticStatisticsEvent).getAsJsonObject(); - assertEquals(7, jsonObject.size()); - assertEquals("diagnostic", diagnosticStatisticsEvent.kind); - assertEquals(2000, jsonObject.getAsJsonPrimitive("creationDate").getAsLong()); - JsonObject idObject = jsonObject.getAsJsonObject("id"); - assertEquals("DK_KEY", idObject.getAsJsonPrimitive("sdkKeySuffix").getAsString()); - assertNotNull(UUID.fromString(idObject.getAsJsonPrimitive("diagnosticId").getAsString())); - assertEquals(1000, jsonObject.getAsJsonPrimitive("dataSinceDate").getAsLong()); - assertEquals(1, jsonObject.getAsJsonPrimitive("droppedEvents").getAsLong()); - assertEquals(2, jsonObject.getAsJsonPrimitive("deduplicatedUsers").getAsLong()); - assertEquals(3, jsonObject.getAsJsonPrimitive("eventsInQueue").getAsLong()); - } - -} From 4af952fe2c927ce0e97c2e39c2fe7985633b775b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Nov 2019 11:39:35 -0800 Subject: [PATCH 202/327] fix NPE when serializing a user that wasn't created through the builder --- .../java/com/launchdarkly/client/LDUser.java | 20 ++++++++++--------- .../com/launchdarkly/client/LDUserTest.java | 11 ++++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index f1c241b0e..be6d2bc99 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -103,40 +103,42 @@ String getKeyAsString() { return key.stringValue(); } + // All of the LDValue getters are guaranteed not to return null (although the LDValue may *be* a JSON null). + LDValue getIp() { - return ip; + return LDValue.normalize(ip); } LDValue getCountry() { - return country; + return LDValue.normalize(country); } LDValue getSecondary() { - return secondary; + return LDValue.normalize(secondary); } LDValue getName() { - return name; + return LDValue.normalize(name); } LDValue getFirstName() { - return firstName; + return LDValue.normalize(firstName); } LDValue getLastName() { - return lastName; + return LDValue.normalize(lastName); } LDValue getEmail() { - return email; + return LDValue.normalize(email); } LDValue getAvatar() { - return avatar; + return LDValue.normalize(avatar); } LDValue getAnonymous() { - return anonymous; + return LDValue.normalize(anonymous); } LDValue getCustom(String key) { diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index 971ece767..0bc1a32fa 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -386,6 +386,17 @@ public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { assertEquals(ImmutableSet.of("name", "foo"), getPrivateAttrs(o)); } + @Test + public void privateAttributeEncodingWorksForMinimalUser() { + LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); + LDUser user = new LDUser("userkey"); + + JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + JsonObject expected = new JsonObject(); + expected.addProperty("key", "userkey"); + assertEquals(expected, o); + } + @Test public void getValueGetsBuiltInAttribute() { LDUser user = new LDUser.Builder("key") From 6078514240158b014defe5cb847b1973f245c635 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Nov 2019 12:30:17 -0800 Subject: [PATCH 203/327] normalize properties in constructor, add unit test --- .../java/com/launchdarkly/client/LDUser.java | 20 ++++++++++--------- .../com/launchdarkly/client/LDUserTest.java | 19 +++++++++++++++++- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index be6d2bc99..fa3a4669f 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -81,6 +81,8 @@ protected LDUser(Builder builder) { */ public LDUser(String key) { this.key = LDValue.of(key); + this.secondary = this.ip = this.email = this.name = this.avatar = this.firstName = this.lastName = this.anonymous = this.country = + LDValue.ofNull(); this.custom = null; this.privateAttributeNames = null; } @@ -106,39 +108,39 @@ String getKeyAsString() { // All of the LDValue getters are guaranteed not to return null (although the LDValue may *be* a JSON null). LDValue getIp() { - return LDValue.normalize(ip); + return ip; } LDValue getCountry() { - return LDValue.normalize(country); + return country; } LDValue getSecondary() { - return LDValue.normalize(secondary); + return secondary; } LDValue getName() { - return LDValue.normalize(name); + return name; } LDValue getFirstName() { - return LDValue.normalize(firstName); + return firstName; } LDValue getLastName() { - return LDValue.normalize(lastName); + return lastName; } LDValue getEmail() { - return LDValue.normalize(email); + return email; } LDValue getAvatar() { - return LDValue.normalize(avatar); + return avatar; } LDValue getAnonymous() { - return LDValue.normalize(anonymous); + return anonymous; } LDValue getCustom(String key) { diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index 0bc1a32fa..ef0471525 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -31,7 +31,24 @@ public class LDUserTest { private static final Gson defaultGson = new Gson(); @Test - public void testLDUserConstructor() { + public void simpleConstructorSetsAttributes() { + LDUser user = new LDUser("key"); + assertEquals(LDValue.of("key"), user.getKey()); + assertEquals("key", user.getKeyAsString()); + assertEquals(LDValue.ofNull(), user.getSecondary()); + assertEquals(LDValue.ofNull(), user.getIp()); + assertEquals(LDValue.ofNull(), user.getFirstName()); + assertEquals(LDValue.ofNull(), user.getLastName()); + assertEquals(LDValue.ofNull(), user.getEmail()); + assertEquals(LDValue.ofNull(), user.getName()); + assertEquals(LDValue.ofNull(), user.getAvatar()); + assertEquals(LDValue.ofNull(), user.getAnonymous()); + assertEquals(LDValue.ofNull(), user.getCountry()); + assertEquals(LDValue.ofNull(), user.getCustom("x")); + } + + @Test + public void canCopyUserWithBuilder() { LDUser user = new LDUser.Builder("key") .secondary("secondary") .ip("127.0.0.1") From 3065d33e8d510024d3423dd2e13946a293a1a3e1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Nov 2019 12:45:57 -0800 Subject: [PATCH 204/327] Temporary revert of ch56503 new features, to allow patch release for other fixes This reverts commit 5b6fd8e225cfdf65f03298551f7855632895b685, reversing changes made to 2f4e7813b55b75eb193764f0dc95892dbdd30a90. --- .../client/value/ArrayBuilder.java | 56 +-------- .../client/value/ObjectBuilder.java | 60 ---------- .../client/value/LDValueTest.java | 109 ++---------------- 3 files changed, 11 insertions(+), 214 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java index e68b7a204..2dc386c69 100644 --- a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java +++ b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java @@ -19,61 +19,7 @@ public ArrayBuilder add(LDValue value) { builder.add(value); return this; } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(boolean value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(int value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(long value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(float value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(double value) { - return add(LDValue.of(value)); - } - - /** - * Adds a new element to the builder. - * @param value the new element - * @return the same builder - */ - public ArrayBuilder add(String value) { - return add(LDValue.of(value)); - } - + /** * Returns an array containing the builder's current elements. Subsequent changes to the builder * will not affect this value (it uses copy-on-write logic, so the previous values will only be diff --git a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java index 1027652d9..0f08a0c93 100644 --- a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java +++ b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java @@ -31,66 +31,6 @@ public ObjectBuilder put(String key, LDValue value) { return this; } - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, boolean value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, int value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, long value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, float value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, double value) { - return put(key, LDValue.of(value)); - } - - /** - * Sets a key-value pair in the builder, overwriting any previous value for that key. - * @param key a string key - * @param value a value - * @return the same builder - */ - public ObjectBuilder put(String key, String value) { - return put(key, LDValue.of(value)); - } - /** * Returns an object containing the builder's current elements. Subsequent changes to the builder * will not affect this value (it uses copy-on-write logic, so the previous values will only be diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index e4397a676..a02a94bc9 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -3,8 +3,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import org.junit.Test; @@ -312,105 +310,18 @@ public void nonObjectValuesBehaveLikeEmptyObject() { } } } - - @Test - public void testEqualsAndHashCodeForPrimitives() - { - assertValueAndHashEqual(LDValue.ofNull(), LDValue.ofNull()); - assertValueAndHashEqual(LDValue.of(true), LDValue.of(true)); - assertValueAndHashNotEqual(LDValue.of(true), LDValue.of(false)); - assertValueAndHashEqual(LDValue.of(1), LDValue.of(1)); - assertValueAndHashEqual(LDValue.of(1), LDValue.of(1.0f)); - assertValueAndHashNotEqual(LDValue.of(1), LDValue.of(2)); - assertValueAndHashEqual(LDValue.of("a"), LDValue.of("a")); - assertValueAndHashNotEqual(LDValue.of("a"), LDValue.of("b")); - assertNotEquals(LDValue.of(false), LDValue.of(0)); - } - - private void assertValueAndHashEqual(LDValue a, LDValue b) - { - assertEquals(a, b); - assertEquals(a.hashCode(), b.hashCode()); - } - - private void assertValueAndHashNotEqual(LDValue a, LDValue b) - { - assertNotEquals(a, b); - assertNotEquals(a.hashCode(), b.hashCode()); - } - + @Test public void samePrimitivesWithOrWithoutJsonElementAreEqual() { - assertValueAndHashEqual(aTrueBoolValue, aTrueBoolValueFromJsonElement); - assertValueAndHashEqual(anIntValue, anIntValueFromJsonElement); - assertValueAndHashEqual(aLongValue, aLongValueFromJsonElement); - assertValueAndHashEqual(aFloatValue, aFloatValueFromJsonElement); - assertValueAndHashEqual(aStringValue, aStringValueFromJsonElement); - assertValueAndHashEqual(anArrayValue, anArrayValueFromJsonElement); - assertValueAndHashEqual(anObjectValue, anObjectValueFromJsonElement); - } - - @Test - public void equalsUsesDeepEqualityForArrays() - { - LDValue a0 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("c").build()) - .build(); - JsonArray ja1 = new JsonArray(); - ja1.add(new JsonPrimitive("a")); - JsonArray ja1a = new JsonArray(); - ja1a.add(new JsonPrimitive("b")); - ja1a.add(new JsonPrimitive("c")); - ja1.add(ja1a); - LDValue a1 = LDValue.fromJsonElement(ja1); - assertValueAndHashEqual(a0, a1); - - LDValue a2 = LDValue.buildArray().add("a").build(); - assertValueAndHashNotEqual(a0, a2); - - LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); - assertValueAndHashNotEqual(a0, a3); - - LDValue a4 = LDValue.buildArray().add("a") - .add(LDValue.buildArray().add("b").add("x").build()) - .build(); - assertValueAndHashNotEqual(a0, a4); - } - - @Test - public void equalsUsesDeepEqualityForObjects() - { - LDValue o0 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .build(); - JsonObject jo1 = new JsonObject(); - jo1.add("a", new JsonPrimitive("b")); - JsonObject jo1a = new JsonObject(); - jo1a.add("d", new JsonPrimitive("e")); - jo1.add("c", jo1a); - LDValue o1 = LDValue.fromJsonElement(jo1); - assertValueAndHashEqual(o0, o1); - - LDValue o2 = LDValue.buildObject() - .put("a", "b") - .build(); - assertValueAndHashNotEqual(o0, o2); - - LDValue o3 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "e").build()) - .put("f", "g") - .build(); - assertValueAndHashNotEqual(o0, o3); - - LDValue o4 = LDValue.buildObject() - .put("a", "b") - .put("c", LDValue.buildObject().put("d", "f").build()) - .build(); - assertValueAndHashNotEqual(o0, o4); + assertEquals(aTrueBoolValue, aTrueBoolValueFromJsonElement); + assertEquals(anIntValue, anIntValueFromJsonElement); + assertEquals(aLongValue, aLongValueFromJsonElement); + assertEquals(aFloatValue, aFloatValueFromJsonElement); + assertEquals(aStringValue, aStringValueFromJsonElement); + assertEquals(anArrayValue, anArrayValueFromJsonElement); + assertEquals(anObjectValue, anObjectValueFromJsonElement); } - + @Test public void canUseLongTypeForNumberGreaterThanMaxInt() { long n = (long)Integer.MAX_VALUE + 1; @@ -426,7 +337,7 @@ public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { assertEquals(n, LDValue.Convert.Double.toType(LDValue.of(n)).doubleValue(), 0); assertEquals(n, LDValue.Convert.Double.fromType(n).doubleValue(), 0); } - + @Test public void testToJsonString() { assertEquals("null", LDValue.ofNull().toJsonString()); From 6b407626b943312de69cc36669ab862144f5614b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Nov 2019 20:42:48 -0500 Subject: [PATCH 205/327] fix bug in summary event format and add test coverage (#148) * fix summary event output bug (2 entries for same flag) due to String == * sort private attrs list for test determinacy * add full test coverage for event output formatting * comment * clarify order of operations --- .../client/EventOutputFormatter.java | 2 +- .../java/com/launchdarkly/client/LDUser.java | 5 +- .../launchdarkly/client/EventOutputTest.java | 412 ++++++++++++++++++ 3 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com/launchdarkly/client/EventOutputTest.java diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index b9fb0f854..e1db41097 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -116,7 +116,7 @@ private void writeSummaryEvent(EventSummarizer.EventSummary summary, JsonWriter for (int j = i; j < unprocessedKeys.length; j++) { CounterKey keyForThisFlag = unprocessedKeys[j]; - if (keyForThisFlag == null || keyForThisFlag.key != flagKey) { + if (j != i && (keyForThisFlag == null || !keyForThisFlag.key.equals(flagKey))) { continue; } CounterValue value = keyForThisFlag == key ? firstValue : summary.counters.get(keyForThisFlag); diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index fa3a4669f..b222644be 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeSet; import java.util.regex.Pattern; /** @@ -192,8 +193,8 @@ public void write(JsonWriter out, LDUser user) throws IOException { return; } - // Collect the private attribute names - Set privateAttributeNames = new HashSet(config.privateAttrNames); + // Collect the private attribute names (use TreeSet to make ordering predictable for tests) + Set privateAttributeNames = new TreeSet(config.privateAttrNames); out.beginObject(); // The key can never be private diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java new file mode 100644 index 000000000..a1f698270 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -0,0 +1,412 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableSet; +import com.google.gson.Gson; +import com.launchdarkly.client.Event.FeatureRequest; +import com.launchdarkly.client.EventSummarizer.EventSummary; +import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.client.value.ObjectBuilder; + +import org.junit.Test; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class EventOutputTest { + private static final Gson gson = new Gson(); + private static final String[] attributesThatCanBePrivate = new String[] { + "avatar", "country", "custom1", "custom2", "email", "firstName", "ip", "lastName", "name", "secondary" + }; + + private LDUser.Builder userBuilderWithAllAttributes = new LDUser.Builder("userkey") + .anonymous(true) + .avatar("http://avatar") + .country("US") + .custom("custom1", "value1") + .custom("custom2", "value2") + .email("test@example.com") + .firstName("first") + .ip("1.2.3.4") + .lastName("last") + .name("me") + .secondary("s"); + private LDValue userJsonWithAllAttributes = parseValue("{" + + "\"key\":\"userkey\"," + + "\"anonymous\":true," + + "\"avatar\":\"http://avatar\"," + + "\"country\":\"US\"," + + "\"custom\":{\"custom1\":\"value1\",\"custom2\":\"value2\"}," + + "\"email\":\"test@example.com\"," + + "\"firstName\":\"first\"," + + "\"ip\":\"1.2.3.4\"," + + "\"lastName\":\"last\"," + + "\"name\":\"me\"," + + "\"secondary\":\"s\"" + + "}"); + + @Test + public void allUserAttributesAreSerialized() throws Exception { + testInlineUserSerialization(userBuilderWithAllAttributes.build(), userJsonWithAllAttributes, + new LDConfig.Builder()); + } + + @Test + public void unsetUserAttributesAreNotSerialized() throws Exception { + LDUser user = new LDUser("userkey"); + LDValue userJson = parseValue("{\"key\":\"userkey\"}"); + testInlineUserSerialization(user, userJson, new LDConfig.Builder()); + } + + @Test + public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { + LDUser user = new LDUser.Builder("userkey").name("me").build(); + LDValue userJson = parseValue("{\"key\":\"userkey\",\"name\":\"me\"}"); + EventOutputFormatter f = new EventOutputFormatter(new LDConfig.Builder().build()); + + Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( + new FeatureFlagBuilder("flag").build(), + user, + new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + LDValue.ofNull()); + LDValue outputEvent = getSingleOutputEvent(f, featureEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("user")); + assertEquals(user.getKey(), outputEvent.get("userKey")); + + Event.Identify identifyEvent = EventFactory.DEFAULT.newIdentifyEvent(user); + outputEvent = getSingleOutputEvent(f, identifyEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); + assertEquals(userJson, outputEvent.get("user")); + + Event.Custom customEvent = EventFactory.DEFAULT.newCustomEvent("custom", user, LDValue.ofNull(), null); + outputEvent = getSingleOutputEvent(f, customEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("user")); + assertEquals(user.getKey(), outputEvent.get("userKey")); + + Event.Index indexEvent = new Event.Index(0, user); + outputEvent = getSingleOutputEvent(f, indexEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); + assertEquals(userJson, outputEvent.get("user")); + } + + @Test + public void allAttributesPrivateMakesAttributesPrivate() throws Exception { + LDUser user = userBuilderWithAllAttributes.build(); + LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); + testPrivateAttributes(config, user, attributesThatCanBePrivate); + } + + @Test + public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception { + LDUser user = userBuilderWithAllAttributes.build(); + for (String attrName: attributesThatCanBePrivate) { + LDConfig config = new LDConfig.Builder().privateAttributeNames(attrName).build(); + testPrivateAttributes(config, user, attrName); + } + } + + @Test + public void perUserPrivateAttributesMakeAttributePrivate() throws Exception { + LDUser baseUser = userBuilderWithAllAttributes.build(); + LDConfig config = new LDConfig.Builder().build(); + + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateAvatar("x").build(), "avatar"); + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateCountry("US").build(), "country"); + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateCustom("custom1", "x").build(), "custom1"); + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateEmail("x").build(), "email"); + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateFirstName("x").build(), "firstName"); + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateLastName("x").build(), "lastName"); + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateName("x").build(), "name"); + testPrivateAttributes(config, new LDUser.Builder(baseUser).privateSecondary("x").build(), "secondary"); + } + + private void testPrivateAttributes(LDConfig config, LDUser user, String... privateAttrNames) throws IOException { + EventOutputFormatter f = new EventOutputFormatter(config); + Set privateAttrNamesSet = ImmutableSet.copyOf(privateAttrNames); + Event.Identify identifyEvent = EventFactory.DEFAULT.newIdentifyEvent(user); + LDValue outputEvent = getSingleOutputEvent(f, identifyEvent); + LDValue userJson = outputEvent.get("user"); + + ObjectBuilder o = LDValue.buildObject(); + for (String key: userJsonWithAllAttributes.keys()) { + LDValue value = userJsonWithAllAttributes.get(key); + if (!privateAttrNamesSet.contains(key)) { + if (key.equals("custom")) { + ObjectBuilder co = LDValue.buildObject(); + for (String customKey: value.keys()) { + if (!privateAttrNamesSet.contains(customKey)) { + co.put(customKey, value.get(customKey)); + } + } + LDValue custom = co.build(); + if (custom.size() > 0) { + o.put(key, custom); + } + } else { + o.put(key, value); + } + } + } + o.put("privateAttrs", LDValue.Convert.String.arrayOf(privateAttrNames)); + + assertEquals(o.build(), userJson); + } + + @Test + public void featureEventIsSerialized() throws Exception { + EventFactory factory = eventFactoryWithTimestamp(100000, false); + EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); + FeatureFlag flag = new FeatureFlagBuilder("flag").version(11).build(); + LDUser user = new LDUser.Builder("userkey").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); + + FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, + new EvaluationDetail(EvaluationReason.off(), 1, LDValue.of("flagvalue")), + LDValue.of("defaultvalue")); + LDValue feJson1 = parseValue("{" + + "\"kind\":\"feature\"," + + "\"creationDate\":100000," + + "\"key\":\"flag\"," + + "\"version\":11," + + "\"userKey\":\"userkey\"," + + "\"value\":\"flagvalue\"," + + "\"variation\":1," + + "\"default\":\"defaultvalue\"" + + "}"); + assertEquals(feJson1, getSingleOutputEvent(f, feWithVariation)); + + FeatureRequest feWithoutVariationOrDefault = factory.newFeatureRequestEvent(flag, user, + new EvaluationDetail(EvaluationReason.off(), null, LDValue.of("flagvalue")), + LDValue.ofNull()); + LDValue feJson2 = parseValue("{" + + "\"kind\":\"feature\"," + + "\"creationDate\":100000," + + "\"key\":\"flag\"," + + "\"version\":11," + + "\"userKey\":\"userkey\"," + + "\"value\":\"flagvalue\"" + + "}"); + assertEquals(feJson2, getSingleOutputEvent(f, feWithoutVariationOrDefault)); + + FeatureRequest feWithReason = factoryWithReason.newFeatureRequestEvent(flag, user, + new EvaluationDetail(EvaluationReason.ruleMatch(1, "id"), 1, LDValue.of("flagvalue")), + LDValue.of("defaultvalue")); + LDValue feJson3 = parseValue("{" + + "\"kind\":\"feature\"," + + "\"creationDate\":100000," + + "\"key\":\"flag\"," + + "\"version\":11," + + "\"userKey\":\"userkey\"," + + "\"value\":\"flagvalue\"," + + "\"variation\":1," + + "\"default\":\"defaultvalue\"," + + "\"reason\":{\"kind\":\"RULE_MATCH\",\"ruleIndex\":1,\"ruleId\":\"id\"}" + + "}"); + assertEquals(feJson3, getSingleOutputEvent(f, feWithReason)); + + FeatureRequest feUnknownFlag = factoryWithReason.newUnknownFeatureRequestEvent("flag", user, + LDValue.of("defaultvalue"), EvaluationReason.ErrorKind.FLAG_NOT_FOUND); + LDValue feJson4 = parseValue("{" + + "\"kind\":\"feature\"," + + "\"creationDate\":100000," + + "\"key\":\"flag\"," + + "\"userKey\":\"userkey\"," + + "\"value\":\"defaultvalue\"," + + "\"default\":\"defaultvalue\"," + + "\"reason\":{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}" + + "}"); + assertEquals(feJson4, getSingleOutputEvent(f, feUnknownFlag)); + + Event.FeatureRequest debugEvent = factory.newDebugEvent(feWithVariation); + LDValue feJson5 = parseValue("{" + + "\"kind\":\"debug\"," + + "\"creationDate\":100000," + + "\"key\":\"flag\"," + + "\"version\":11," + + "\"user\":{\"key\":\"userkey\",\"name\":\"me\"}," + + "\"value\":\"flagvalue\"," + + "\"variation\":1," + + "\"default\":\"defaultvalue\"" + + "}"); + assertEquals(feJson5, getSingleOutputEvent(f, debugEvent)); + } + + @Test + public void identifyEventIsSerialized() throws IOException { + EventFactory factory = eventFactoryWithTimestamp(100000, false); + LDUser user = new LDUser.Builder("userkey").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); + + Event.Identify ie = factory.newIdentifyEvent(user); + LDValue ieJson = parseValue("{" + + "\"kind\":\"identify\"," + + "\"creationDate\":100000," + + "\"key\":\"userkey\"," + + "\"user\":{\"key\":\"userkey\",\"name\":\"me\"}" + + "}"); + assertEquals(ieJson, getSingleOutputEvent(f, ie)); + } + + @Test + public void customEventIsSerialized() throws IOException { + EventFactory factory = eventFactoryWithTimestamp(100000, false); + LDUser user = new LDUser.Builder("userkey").name("me").build(); + EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); + + Event.Custom ceWithoutData = factory.newCustomEvent("customkey", user, LDValue.ofNull(), null); + LDValue ceJson1 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"userKey\":\"userkey\"" + + "}"); + assertEquals(ceJson1, getSingleOutputEvent(f, ceWithoutData)); + + Event.Custom ceWithData = factory.newCustomEvent("customkey", user, LDValue.of("thing"), null); + LDValue ceJson2 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"userKey\":\"userkey\"," + + "\"data\":\"thing\"" + + "}"); + assertEquals(ceJson2, getSingleOutputEvent(f, ceWithData)); + + Event.Custom ceWithMetric = factory.newCustomEvent("customkey", user, LDValue.ofNull(), 2.5); + LDValue ceJson3 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"userKey\":\"userkey\"," + + "\"metricValue\":2.5" + + "}"); + assertEquals(ceJson3, getSingleOutputEvent(f, ceWithMetric)); + + Event.Custom ceWithDataAndMetric = factory.newCustomEvent("customkey", user, LDValue.of("thing"), 2.5); + LDValue ceJson4 = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"userKey\":\"userkey\"," + + "\"data\":\"thing\"," + + "\"metricValue\":2.5" + + "}"); + assertEquals(ceJson4, getSingleOutputEvent(f, ceWithDataAndMetric)); + } + + @Test + public void summaryEventIsSerialized() throws Exception { + EventSummary summary = new EventSummary(); + summary.noteTimestamp(1001); + + // Note use of "new String()" to ensure that these flag keys are not interned, as string literals normally are - + // we found a bug where strings were being compared by reference equality. + + summary.incrementCounter(new String("first"), 1, 11, LDValue.of("value1a"), LDValue.of("default1")); + + summary.incrementCounter(new String("second"), 1, 21, LDValue.of("value2a"), LDValue.of("default2")); + + summary.incrementCounter(new String("first"), 1, 11, LDValue.of("value1a"), LDValue.of("default1")); + summary.incrementCounter(new String("first"), 1, 12, LDValue.of("value1a"), LDValue.of("default1")); + + summary.incrementCounter(new String("second"), 2, 21, LDValue.of("value2b"), LDValue.of("default2")); + summary.incrementCounter(new String("second"), null, 21, LDValue.of("default2"), LDValue.of("default2")); // flag exists (has version), but eval failed (no variation) + + summary.incrementCounter(new String("third"), null, null, LDValue.of("default3"), LDValue.of("default3")); // flag doesn't exist (no version) + + summary.noteTimestamp(1000); + summary.noteTimestamp(1002); + + EventOutputFormatter f = new EventOutputFormatter(new LDConfig.Builder().build()); + StringWriter w = new StringWriter(); + int count = f.writeOutputEvents(new Event[0], summary, w); + assertEquals(1, count); + LDValue outputEvent = parseValue(w.toString()).get(0); + + assertEquals("summary", outputEvent.get("kind").stringValue()); + assertEquals(1000, outputEvent.get("startDate").intValue()); + assertEquals(1002, outputEvent.get("endDate").intValue()); + + LDValue featuresJson = outputEvent.get("features"); + assertEquals(3, featuresJson.size()); + + LDValue firstJson = featuresJson.get("first"); + assertEquals("default1", firstJson.get("default").stringValue()); + assertThat(firstJson.get("counters").values(), containsInAnyOrder( + parseValue("{\"value\":\"value1a\",\"variation\":1,\"version\":11,\"count\":2}"), + parseValue("{\"value\":\"value1a\",\"variation\":1,\"version\":12,\"count\":1}") + )); + + LDValue secondJson = featuresJson.get("second"); + assertEquals("default2", secondJson.get("default").stringValue()); + assertThat(secondJson.get("counters").values(), containsInAnyOrder( + parseValue("{\"value\":\"value2a\",\"variation\":1,\"version\":21,\"count\":1}"), + parseValue("{\"value\":\"value2b\",\"variation\":2,\"version\":21,\"count\":1}"), + parseValue("{\"value\":\"default2\",\"version\":21,\"count\":1}") + )); + + LDValue thirdJson = featuresJson.get("third"); + assertEquals("default3", thirdJson.get("default").stringValue()); + assertThat(thirdJson.get("counters").values(), contains( + parseValue("{\"unknown\":true,\"value\":\"default3\",\"count\":1}") + )); + } + + private LDValue parseValue(String json) { + return gson.fromJson(json, LDValue.class); + } + + private EventFactory eventFactoryWithTimestamp(final long timestamp, final boolean includeReasons) { + return new EventFactory() { + protected long getTimestamp() { + return timestamp; + } + + protected boolean isIncludeReasons() { + return includeReasons; + } + }; + } + + private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws IOException { + StringWriter w = new StringWriter(); + int count = f.writeOutputEvents(new Event[] { event }, new EventSummary(), w); + assertEquals(1, count); + return parseValue(w.toString()).get(0); + } + + private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, LDConfig.Builder baseConfig) throws IOException { + baseConfig.inlineUsersInEvents(true); + EventOutputFormatter f = new EventOutputFormatter(baseConfig.build()); + + Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( + new FeatureFlagBuilder("flag").build(), + user, + new EvaluationDetail(EvaluationReason.off(), null, LDValue.ofNull()), + LDValue.ofNull()); + LDValue outputEvent = getSingleOutputEvent(f, featureEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); + assertEquals(expectedJsonValue, outputEvent.get("user")); + + Event.Identify identifyEvent = EventFactory.DEFAULT.newIdentifyEvent(user); + outputEvent = getSingleOutputEvent(f, identifyEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); + assertEquals(expectedJsonValue, outputEvent.get("user")); + + Event.Custom customEvent = EventFactory.DEFAULT.newCustomEvent("custom", user, LDValue.ofNull(), null); + outputEvent = getSingleOutputEvent(f, customEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); + assertEquals(expectedJsonValue, outputEvent.get("user")); + + Event.Index indexEvent = new Event.Index(0, user); + outputEvent = getSingleOutputEvent(f, indexEvent); + assertEquals(LDValue.ofNull(), outputEvent.get("userKey")); + assertEquals(expectedJsonValue, outputEvent.get("user")); + } +} From 4d035f86435b17ba60fadfff54263f40215627b1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 20 Nov 2019 18:32:24 -0800 Subject: [PATCH 206/327] Revert "Temporary revert of ch56503 new features, to allow patch release for other fixes" This reverts commit 3065d33e8d510024d3423dd2e13946a293a1a3e1. --- .../client/value/ArrayBuilder.java | 56 ++++++++- .../client/value/ObjectBuilder.java | 60 ++++++++++ .../client/value/LDValueTest.java | 109 ++++++++++++++++-- 3 files changed, 214 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java index 2dc386c69..e68b7a204 100644 --- a/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java +++ b/src/main/java/com/launchdarkly/client/value/ArrayBuilder.java @@ -19,7 +19,61 @@ public ArrayBuilder add(LDValue value) { builder.add(value); return this; } - + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(boolean value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(int value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(long value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(float value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(double value) { + return add(LDValue.of(value)); + } + + /** + * Adds a new element to the builder. + * @param value the new element + * @return the same builder + */ + public ArrayBuilder add(String value) { + return add(LDValue.of(value)); + } + /** * Returns an array containing the builder's current elements. Subsequent changes to the builder * will not affect this value (it uses copy-on-write logic, so the previous values will only be diff --git a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java index 0f08a0c93..1027652d9 100644 --- a/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java +++ b/src/main/java/com/launchdarkly/client/value/ObjectBuilder.java @@ -31,6 +31,66 @@ public ObjectBuilder put(String key, LDValue value) { return this; } + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, boolean value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, int value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, long value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, float value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, double value) { + return put(key, LDValue.of(value)); + } + + /** + * Sets a key-value pair in the builder, overwriting any previous value for that key. + * @param key a string key + * @param value a value + * @return the same builder + */ + public ObjectBuilder put(String key, String value) { + return put(key, LDValue.of(value)); + } + /** * Returns an object containing the builder's current elements. Subsequent changes to the builder * will not affect this value (it uses copy-on-write logic, so the previous values will only be diff --git a/src/test/java/com/launchdarkly/client/value/LDValueTest.java b/src/test/java/com/launchdarkly/client/value/LDValueTest.java index a02a94bc9..e4397a676 100644 --- a/src/test/java/com/launchdarkly/client/value/LDValueTest.java +++ b/src/test/java/com/launchdarkly/client/value/LDValueTest.java @@ -3,6 +3,8 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import org.junit.Test; @@ -310,18 +312,105 @@ public void nonObjectValuesBehaveLikeEmptyObject() { } } } - + + @Test + public void testEqualsAndHashCodeForPrimitives() + { + assertValueAndHashEqual(LDValue.ofNull(), LDValue.ofNull()); + assertValueAndHashEqual(LDValue.of(true), LDValue.of(true)); + assertValueAndHashNotEqual(LDValue.of(true), LDValue.of(false)); + assertValueAndHashEqual(LDValue.of(1), LDValue.of(1)); + assertValueAndHashEqual(LDValue.of(1), LDValue.of(1.0f)); + assertValueAndHashNotEqual(LDValue.of(1), LDValue.of(2)); + assertValueAndHashEqual(LDValue.of("a"), LDValue.of("a")); + assertValueAndHashNotEqual(LDValue.of("a"), LDValue.of("b")); + assertNotEquals(LDValue.of(false), LDValue.of(0)); + } + + private void assertValueAndHashEqual(LDValue a, LDValue b) + { + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + private void assertValueAndHashNotEqual(LDValue a, LDValue b) + { + assertNotEquals(a, b); + assertNotEquals(a.hashCode(), b.hashCode()); + } + @Test public void samePrimitivesWithOrWithoutJsonElementAreEqual() { - assertEquals(aTrueBoolValue, aTrueBoolValueFromJsonElement); - assertEquals(anIntValue, anIntValueFromJsonElement); - assertEquals(aLongValue, aLongValueFromJsonElement); - assertEquals(aFloatValue, aFloatValueFromJsonElement); - assertEquals(aStringValue, aStringValueFromJsonElement); - assertEquals(anArrayValue, anArrayValueFromJsonElement); - assertEquals(anObjectValue, anObjectValueFromJsonElement); + assertValueAndHashEqual(aTrueBoolValue, aTrueBoolValueFromJsonElement); + assertValueAndHashEqual(anIntValue, anIntValueFromJsonElement); + assertValueAndHashEqual(aLongValue, aLongValueFromJsonElement); + assertValueAndHashEqual(aFloatValue, aFloatValueFromJsonElement); + assertValueAndHashEqual(aStringValue, aStringValueFromJsonElement); + assertValueAndHashEqual(anArrayValue, anArrayValueFromJsonElement); + assertValueAndHashEqual(anObjectValue, anObjectValueFromJsonElement); } - + + @Test + public void equalsUsesDeepEqualityForArrays() + { + LDValue a0 = LDValue.buildArray().add("a") + .add(LDValue.buildArray().add("b").add("c").build()) + .build(); + JsonArray ja1 = new JsonArray(); + ja1.add(new JsonPrimitive("a")); + JsonArray ja1a = new JsonArray(); + ja1a.add(new JsonPrimitive("b")); + ja1a.add(new JsonPrimitive("c")); + ja1.add(ja1a); + LDValue a1 = LDValue.fromJsonElement(ja1); + assertValueAndHashEqual(a0, a1); + + LDValue a2 = LDValue.buildArray().add("a").build(); + assertValueAndHashNotEqual(a0, a2); + + LDValue a3 = LDValue.buildArray().add("a").add("b").add("c").build(); + assertValueAndHashNotEqual(a0, a3); + + LDValue a4 = LDValue.buildArray().add("a") + .add(LDValue.buildArray().add("b").add("x").build()) + .build(); + assertValueAndHashNotEqual(a0, a4); + } + + @Test + public void equalsUsesDeepEqualityForObjects() + { + LDValue o0 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "e").build()) + .build(); + JsonObject jo1 = new JsonObject(); + jo1.add("a", new JsonPrimitive("b")); + JsonObject jo1a = new JsonObject(); + jo1a.add("d", new JsonPrimitive("e")); + jo1.add("c", jo1a); + LDValue o1 = LDValue.fromJsonElement(jo1); + assertValueAndHashEqual(o0, o1); + + LDValue o2 = LDValue.buildObject() + .put("a", "b") + .build(); + assertValueAndHashNotEqual(o0, o2); + + LDValue o3 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "e").build()) + .put("f", "g") + .build(); + assertValueAndHashNotEqual(o0, o3); + + LDValue o4 = LDValue.buildObject() + .put("a", "b") + .put("c", LDValue.buildObject().put("d", "f").build()) + .build(); + assertValueAndHashNotEqual(o0, o4); + } + @Test public void canUseLongTypeForNumberGreaterThanMaxInt() { long n = (long)Integer.MAX_VALUE + 1; @@ -337,7 +426,7 @@ public void canUseDoubleTypeForNumberGreaterThanMaxFloat() { assertEquals(n, LDValue.Convert.Double.toType(LDValue.of(n)).doubleValue(), 0); assertEquals(n, LDValue.Convert.Double.fromType(n).doubleValue(), 0); } - + @Test public void testToJsonString() { assertEquals("null", LDValue.ofNull().toJsonString()); From 72e91e7f7a2ec99b12b72906dd583535a26e2b59 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 22 Nov 2019 22:17:28 +0000 Subject: [PATCH 207/327] [ch56562] More consistency with .NET and fix flaky tests. (#149) --- .../com/launchdarkly/client/Components.java | 19 +- .../client/DefaultEventProcessor.java | 20 +-- .../client/DiagnosticAccumulator.java | 6 +- .../launchdarkly/client/DiagnosticEvent.java | 2 - .../EventProcessorFactoryWithDiagnostics.java | 6 + .../com/launchdarkly/client/LDClient.java | 28 ++- .../com/launchdarkly/client/LDConfig.java | 10 +- .../launchdarkly/client/StreamProcessor.java | 4 +- ...UpdateProcessorFactoryWithDiagnostics.java | 6 + .../client/DefaultEventProcessorTest.java | 167 ++++++++++-------- .../client/DiagnosticAccumulatorTest.java | 32 +--- .../client/DiagnosticEventTest.java | 2 - .../com/launchdarkly/client/LDClientTest.java | 100 ++++++++++- .../client/StreamProcessorTest.java | 8 +- 14 files changed, 264 insertions(+), 146 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java create mode 100644 src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 65c993869..e46dfd733 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -88,13 +88,18 @@ public FeatureStore createFeatureStore() { } } - private static final class DefaultEventProcessorFactory implements EventProcessorFactory { + private static final class DefaultEventProcessorFactory implements EventProcessorFactoryWithDiagnostics { @Override public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { + return createEventProcessor(sdkKey, config, null); + } + + public EventProcessor createEventProcessor(String sdkKey, LDConfig config, + DiagnosticAccumulator diagnosticAccumulator) { if (config.offline || !config.sendEvents) { return new EventProcessor.NullEventProcessor(); } else { - return new DefaultEventProcessor(sdkKey, config); + return new DefaultEventProcessor(sdkKey, config, diagnosticAccumulator); } } } @@ -105,12 +110,18 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { } } - private static final class DefaultUpdateProcessorFactory implements UpdateProcessorFactory { + private static final class DefaultUpdateProcessorFactory implements UpdateProcessorFactoryWithDiagnostics { // Note, logger uses LDClient class name for backward compatibility private static final Logger logger = LoggerFactory.getLogger(LDClient.class); @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { + return createUpdateProcessor(sdkKey, config, featureStore, null); + } + + @Override + public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore, + DiagnosticAccumulator diagnosticAccumulator) { if (config.offline) { logger.info("Starting LaunchDarkly client in offline mode"); return new UpdateProcessor.NullUpdateProcessor(); @@ -121,7 +132,7 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea DefaultFeatureRequestor requestor = new DefaultFeatureRequestor(sdkKey, config); if (config.stream) { logger.info("Enabling streaming API"); - return new StreamProcessor(sdkKey, config, requestor, featureStore, null); + return new StreamProcessor(sdkKey, config, requestor, featureStore, null, diagnosticAccumulator); } else { logger.info("Disabling streaming API"); logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 639229484..637aa251c 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -50,7 +50,7 @@ final class DefaultEventProcessor implements EventProcessor { private final AtomicBoolean closed = new AtomicBoolean(false); private volatile boolean inputCapacityExceeded = false; - DefaultEventProcessor(String sdkKey, LDConfig config) { + DefaultEventProcessor(String sdkKey, LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { inbox = new ArrayBlockingQueue<>(config.capacity); ThreadFactory threadFactory = new ThreadFactoryBuilder() @@ -60,7 +60,7 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - new EventDispatcher(sdkKey, config, inbox, threadFactory, closed); + new EventDispatcher(sdkKey, config, inbox, threadFactory, closed, diagnosticAccumulator); Runnable flusher = new Runnable() { public void run() { @@ -75,7 +75,7 @@ public void run() { }; this.scheduler.scheduleAtFixedRate(userKeysFlusher, config.userKeysFlushInterval, config.userKeysFlushInterval, TimeUnit.SECONDS); - if (!config.diagnosticOptOut) { + if (!config.diagnosticOptOut && diagnosticAccumulator != null) { Runnable diagnosticsTrigger = new Runnable() { public void run() { postMessageAsync(MessageType.DIAGNOSTIC, null); @@ -207,6 +207,7 @@ static final class EventDispatcher { private final Random random = new Random(); private final AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); + private final DiagnosticAccumulator diagnosticAccumulator; private final ExecutorService diagnosticExecutor; private final SendDiagnosticTaskFactory sendDiagnosticTaskFactory; @@ -215,8 +216,10 @@ static final class EventDispatcher { private EventDispatcher(String sdkKey, LDConfig config, final BlockingQueue inbox, ThreadFactory threadFactory, - final AtomicBoolean closed) { + final AtomicBoolean closed, + DiagnosticAccumulator diagnosticAccumulator) { this.config = config; + this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); @@ -270,14 +273,11 @@ public void handleResponse(Response response, Date responseDate) { flushWorkers.add(task); } - if (!config.diagnosticOptOut) { + if (!config.diagnosticOptOut && diagnosticAccumulator != null) { // Set up diagnostics - long currentTime = System.currentTimeMillis(); - DiagnosticId diagnosticId = new DiagnosticId(sdkKey); - config.diagnosticAccumulator.start(diagnosticId, currentTime); this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(sdkKey, config, httpClient); diagnosticExecutor = Executors.newSingleThreadExecutor(threadFactory); - DiagnosticEvent.Init diagnosticInitEvent = new DiagnosticEvent.Init(currentTime, diagnosticId, config); + DiagnosticEvent.Init diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, config); diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); } else { diagnosticExecutor = null; @@ -334,7 +334,7 @@ private void runMainLoop(BlockingQueue inbox, private void sendAndResetDiagnostics(EventBuffer outbox) { long droppedEvents = outbox.getAndClearDroppedCount(); long eventsInQueue = outbox.getEventsInQueueCount(); - DiagnosticEvent diagnosticEvent = config.diagnosticAccumulator.createEventAndReset(droppedEvents, deduplicatedUsers, eventsInQueue); + DiagnosticEvent diagnosticEvent = diagnosticAccumulator.createEventAndReset(droppedEvents, deduplicatedUsers, eventsInQueue); deduplicatedUsers = 0; diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticEvent)); } diff --git a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java index 1ee9b33a0..96068bc1a 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java @@ -2,12 +2,12 @@ class DiagnosticAccumulator { + final DiagnosticId diagnosticId; volatile long dataSinceDate; - volatile DiagnosticId diagnosticId; - void start(DiagnosticId diagnosticId, long dataSinceDate) { + DiagnosticAccumulator(DiagnosticId diagnosticId) { this.diagnosticId = diagnosticId; - this.dataSinceDate = dataSinceDate; + this.dataSinceDate = System.currentTimeMillis(); } DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long deduplicatedUsers, long eventsInQueue) { diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 40c25eb50..284365723 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -55,7 +55,6 @@ static class DiagnosticConfiguration { private final boolean usingRelayDaemon; private final boolean offline; private final boolean allAttributesPrivate; - private final boolean eventReportingDisabled; private final long pollingIntervalMillis; private final long startWaitMillis; private final int samplingInterval; @@ -80,7 +79,6 @@ static class DiagnosticConfiguration { this.usingRelayDaemon = config.useLdd; this.offline = config.offline; this.allAttributesPrivate = config.allAttributesPrivate; - this.eventReportingDisabled = !config.sendEvents; this.pollingIntervalMillis = config.pollingIntervalMillis; this.startWaitMillis = config.startWaitMillis; this.samplingInterval = config.samplingInterval; diff --git a/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java new file mode 100644 index 000000000..fac2c631a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EventProcessorFactoryWithDiagnostics.java @@ -0,0 +1,6 @@ +package com.launchdarkly.client; + +interface EventProcessorFactoryWithDiagnostics extends EventProcessorFactory { + EventProcessor createEventProcessor(String sdkKey, LDConfig config, + DiagnosticAccumulator diagnosticAccumulator); +} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index a6013bb82..67990f22e 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -78,14 +78,32 @@ public LDClient(String sdkKey, LDConfig config) { this.shouldCloseFeatureStore = true; } this.featureStore = new FeatureStoreClientWrapper(store); - + EventProcessorFactory epFactory = this.config.eventProcessorFactory == null ? Components.defaultEventProcessor() : this.config.eventProcessorFactory; - this.eventProcessor = epFactory.createEventProcessor(sdkKey, this.config); - UpdateProcessorFactory upFactory = this.config.updateProcessorFactory == null ? - Components.defaultUpdateProcessor() : this.config.updateProcessorFactory; - this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, this.config, featureStore); + Components.defaultUpdateProcessor() : this.config.updateProcessorFactory; + + DiagnosticAccumulator diagnosticAccumulator = null; + // Do not create accumulator if config has specified is opted out, or if epFactory doesn't support diagnostics + if (!this.config.diagnosticOptOut && epFactory instanceof EventProcessorFactoryWithDiagnostics) { + diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(sdkKey)); + } + + if (epFactory instanceof EventProcessorFactoryWithDiagnostics) { + EventProcessorFactoryWithDiagnostics epwdFactory = ((EventProcessorFactoryWithDiagnostics) epFactory); + this.eventProcessor = epwdFactory.createEventProcessor(sdkKey, this.config, diagnosticAccumulator); + } else { + this.eventProcessor = epFactory.createEventProcessor(sdkKey, this.config); + } + + if (upFactory instanceof UpdateProcessorFactoryWithDiagnostics) { + UpdateProcessorFactoryWithDiagnostics upwdFactory = ((UpdateProcessorFactoryWithDiagnostics) upFactory); + this.updateProcessor = upwdFactory.createUpdateProcessor(sdkKey, this.config, featureStore, diagnosticAccumulator); + } else { + this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, this.config, featureStore); + } + Future startFuture = updateProcessor.start(); if (this.config.startWaitMillis > 0L) { if (!this.config.offline && !this.config.useLdd) { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 18d0c4cc7..cf4fe15ff 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -83,8 +83,6 @@ public final class LDConfig { final TimeUnit connectTimeoutUnit; final int socketTimeout; final TimeUnit socketTimeoutUnit; - - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); protected LDConfig(Builder builder) { this.baseURI = builder.baseURI; @@ -628,7 +626,7 @@ public Builder inlineUsersInEvents(boolean inlineUsersInEvents) { /** * Sets the interval at which periodic diagnostic data is sent. The default is every 15 minutes (900,000 - * milliseconds) and the minimum value is 6000. + * milliseconds) and the minimum value is 60,000. * * @see #diagnosticOptOut(boolean) * @@ -658,8 +656,8 @@ public Builder diagnosticOptOut(boolean diagnosticOptOut) { } /** - * For use by wrapper libraries to set an identifying name for the wrapper being used. This will be sent in - * User-Agent headers during requests to the LaunchDarkly servers to allow recording metrics on the usage of + * For use by wrapper libraries to set an identifying name for the wrapper being used. This will be included in a + * header during requests to the LaunchDarkly servers to allow recording metrics on the usage of * these wrapper libraries. * * @param wrapperName an identifying name for the wrapper library @@ -672,7 +670,7 @@ public Builder wrapperName(String wrapperName) { /** * For use by wrapper libraries to report the version of the library in use. If {@link #wrapperName(String)} is not - * set, this field will be ignored. Otherwise the version string will be included in the User-Agent headers along + * set, this field will be ignored. Otherwise the version string will be included in a header along * with the wrapperName during requests to the LaunchDarkly servers. * * @param wrapperVersion Version string for the wrapper library diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index fb0d2c09d..477c37a93 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -40,6 +40,7 @@ final class StreamProcessor implements UpdateProcessor { private final LDConfig config; private final String sdkKey; private final FeatureRequestor requestor; + private final DiagnosticAccumulator diagnosticAccumulator; private final EventSourceCreator eventSourceCreator; private volatile EventSource es; private final AtomicBoolean initialized = new AtomicBoolean(false); @@ -51,11 +52,12 @@ public static interface EventSourceCreator { } StreamProcessor(String sdkKey, LDConfig config, FeatureRequestor requestor, FeatureStore featureStore, - EventSourceCreator eventSourceCreator) { + EventSourceCreator eventSourceCreator, DiagnosticAccumulator diagnosticAccumulator) { this.store = featureStore; this.config = config; this.sdkKey = sdkKey; this.requestor = requestor; + this.diagnosticAccumulator = diagnosticAccumulator; this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : new DefaultEventSourceCreator(); } diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java b/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java new file mode 100644 index 000000000..a7a63bb31 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/UpdateProcessorFactoryWithDiagnostics.java @@ -0,0 +1,6 @@ +package com.launchdarkly.client; + +interface UpdateProcessorFactoryWithDiagnostics extends UpdateProcessorFactory { + UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore, + DiagnosticAccumulator diagnosticAccumulator); +} diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 08c27eebd..2ec5c644e 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -13,6 +13,10 @@ import java.util.Date; import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.hasJsonProperty; @@ -24,14 +28,11 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.samePropertyValuesAs; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; - @SuppressWarnings("javadoc") public class DefaultEventProcessorTest { private static final String SDK_KEY = "SDK_KEY"; @@ -51,7 +52,7 @@ public void identifyEventIsQueued() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(e); } @@ -68,7 +69,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).allAttributesPrivate(true).build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { ep.sendEvent(e); } @@ -86,7 +87,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(fe); } @@ -108,7 +109,7 @@ public void userIsFilteredInIndexEvent() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).allAttributesPrivate(true).build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { ep.sendEvent(fe); } @@ -130,7 +131,7 @@ public void featureEventCanContainInlineUser() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { ep.sendEvent(fe); } @@ -151,7 +152,7 @@ public void userIsFilteredInFeatureEvent() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).allAttributesPrivate(true).build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { ep.sendEvent(fe); } @@ -171,7 +172,7 @@ public void featureEventCanContainReason() throws Exception { EvaluationDetail.fromValue(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(fe); } @@ -193,7 +194,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { ep.sendEvent(fe); } @@ -213,7 +214,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(fe); } @@ -235,7 +236,7 @@ public void eventCanBeBothTrackedAndDebugged() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(fe); } @@ -262,7 +263,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(resp1, resp2)) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); ep.flush(); @@ -295,7 +296,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(resp1, resp2)) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { // Send and flush an event we don't care about, just to set the last server time ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); @@ -328,7 +329,7 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except simpleEvaluation(1, value), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(fe1); ep.sendEvent(fe2); } @@ -351,7 +352,7 @@ public void identifyEventMakesIndexEventUnnecessary() throws Exception { simpleEvaluation(1, LDValue.of("value")), null); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(ie); ep.sendEvent(fe); } @@ -383,7 +384,7 @@ public void nonTrackedEventsAreSummarized() throws Exception { simpleEvaluation(2, value2), default2); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(fe1a); ep.sendEvent(fe1b); ep.sendEvent(fe1c); @@ -414,7 +415,7 @@ public void customEventIsQueuedWithUser() throws Exception { Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, metric); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(ce); } @@ -433,7 +434,7 @@ public void customEventCanContainInlineUser() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { ep.sendEvent(ce); } @@ -449,7 +450,7 @@ public void userIsFilteredInCustomEvent() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = baseConfig(server).inlineUsersInEvents(true).allAttributesPrivate(true).build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { ep.sendEvent(ce); } @@ -462,7 +463,7 @@ public void closingEventProcessorForcesSynchronousFlush() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(e); } @@ -473,7 +474,7 @@ public void closingEventProcessorForcesSynchronousFlush() throws Exception { @Test public void nothingIsSentIfThereAreNoEvents() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build()); + DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build()); ep.close(); assertEquals(0, server.getRequestCount()); @@ -481,55 +482,63 @@ public void nothingIsSentIfThereAreNoEvents() throws Exception { } @Test - public void initialDiagnosticEventSentToDiagnosticEndpoint() throws Exception { + public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build()); - ep.close(); - - RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); - - assertNotNull(req); - assertThat(req.getPath(), equalTo("//diagnostic")); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build(), diagnosticAccumulator)) { + RecordedRequest initReq = server.takeRequest(); + ep.postDiagnostic(); + RecordedRequest periodicReq = server.takeRequest(); + + assertThat(initReq.getPath(), equalTo("//diagnostic")); + assertThat(periodicReq.getPath(), equalTo("//diagnostic")); + } } } @Test public void initialDiagnosticEventHasInitBody() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build()); - ep.close(); - - RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); - assertNotNull(req); + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build(), diagnosticAccumulator)) { + RecordedRequest req = server.takeRequest(); + + assertNotNull(req); - DiagnosticEvent.Init initEvent = gson.fromJson(req.getBody().readUtf8(), DiagnosticEvent.Init.class); + DiagnosticEvent.Init initEvent = gson.fromJson(req.getBody().readUtf8(), DiagnosticEvent.Init.class); - assertNotNull(initEvent); - assertThat(initEvent.kind, equalTo("diagnostic-init")); - assertNotNull(initEvent.configuration); - assertNotNull(initEvent.sdk); - assertNotNull(initEvent.platform); - assertNotNull(initEvent.id); + assertNotNull(initEvent); + assertThat(initEvent.kind, equalTo("diagnostic-init")); + assertThat(initEvent.id, samePropertyValuesAs(diagnosticId)); + assertNotNull(initEvent.configuration); + assertNotNull(initEvent.sdk); + assertNotNull(initEvent.platform); + } } } @Test public void periodicDiagnosticEventHasStatisticsBody() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse(), eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build())) { + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + long dataSinceDate = diagnosticAccumulator.dataSinceDate; + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build(), diagnosticAccumulator)) { + // Ignore the initial diagnostic event + server.takeRequest(); ep.postDiagnostic(); - } - - // Ignore the initial diagnostic event - server.takeRequest(100, TimeUnit.MILLISECONDS); - RecordedRequest periodReq = server.takeRequest(100, TimeUnit.MILLISECONDS); - assertNotNull(periodReq); + RecordedRequest periodicReq = server.takeRequest(); - DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodReq.getBody().readUtf8(), DiagnosticEvent.Statistics.class); + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.getBody().readUtf8(), DiagnosticEvent.Statistics.class); - assertNotNull(statsEvent); - assertThat(statsEvent.kind, equalTo("diagnostic")); - assertNotNull(statsEvent.id); + assertNotNull(statsEvent); + assertThat(statsEvent.kind, equalTo("diagnostic")); + assertThat(statsEvent.id, samePropertyValuesAs(diagnosticId)); + assertThat(statsEvent.dataSinceDate, equalTo(dataSinceDate)); + assertThat(statsEvent.creationDate, equalTo(diagnosticAccumulator.dataSinceDate)); + } } } @@ -538,7 +547,7 @@ public void sdkKeyIsSent() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(e); } @@ -550,12 +559,15 @@ public void sdkKeyIsSent() throws Exception { @Test public void sdkKeyIsSentOnDiagnosticEvents() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build()); - ep.close(); - - RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); - assertNotNull(req); - assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build(), diagnosticAccumulator)) { + RecordedRequest initReq = server.takeRequest(); + ep.postDiagnostic(); + RecordedRequest periodicReq = server.takeRequest(); + + assertThat(initReq.getHeader("Authorization"), equalTo(SDK_KEY)); + assertThat(periodicReq.getHeader("Authorization"), equalTo(SDK_KEY)); + } } } @@ -564,7 +576,7 @@ public void eventSchemaIsSent() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(e); } @@ -576,12 +588,15 @@ public void eventSchemaIsSent() throws Exception { @Test public void eventSchemaNotSetOnDiagnosticEvents() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build()); - ep.close(); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build(), diagnosticAccumulator)) { + RecordedRequest initReq = server.takeRequest(); + ep.postDiagnostic(); + RecordedRequest periodicReq = server.takeRequest(); - RecordedRequest req = server.takeRequest(100, TimeUnit.MILLISECONDS); - assertNotNull(req); - assertNull(req.getHeader("X-LaunchDarkly-Event-Schema")); + assertNull(initReq.getHeader("X-LaunchDarkly-Event-Schema")); + assertNull(periodicReq.getHeader("X-LaunchDarkly-Event-Schema")); + } } } @@ -592,7 +607,7 @@ public void wrapperHeaderSentWhenSet() throws Exception { .wrapperName("Scala") .wrapperVersion("0.1.0") .build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); } @@ -609,7 +624,7 @@ public void wrapperHeaderSentWithoutVersion() throws Exception { .wrapperName("Scala") .build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = createBasicProcessor(config)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); } @@ -662,7 +677,7 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { .eventsURI(serverWithCert.uri()) .build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config)) { + try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config, null)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); @@ -683,7 +698,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .diagnosticOptOut(true) .build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config)) { + try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config, null)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); @@ -699,7 +714,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(e); } @@ -717,7 +732,7 @@ private void testRecoverableHttpError(int status) throws Exception { // send two errors in a row, because the flush will be retried one time try (MockWebServer server = makeStartedServer(errorResponse, errorResponse, eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = createBasicProcessor(baseConfig(server).build())) { ep.sendEvent(e); } @@ -730,6 +745,10 @@ private void testRecoverableHttpError(int status) throws Exception { } } + private DefaultEventProcessor createBasicProcessor(LDConfig config) { + return new DefaultEventProcessor(SDK_KEY, config, null); + } + private LDConfig.Builder baseConfig(MockWebServer server) { return new LDConfig.Builder().eventsURI(server.url("/").uri()).diagnosticOptOut(true); } diff --git a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java b/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java index 2e261f920..83468a1af 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java @@ -8,45 +8,25 @@ public class DiagnosticAccumulatorTest { - @Test - public void startSetsDiagnosticId() { - DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); - long currentTime = System.currentTimeMillis(); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); - diagnosticAccumulator.start(diagnosticId, currentTime); - assertSame(diagnosticId, diagnosticAccumulator.diagnosticId); - } - - @Test - public void startSetsDataSinceDate() { - DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); - long currentTime = System.currentTimeMillis(); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); - diagnosticAccumulator.start(diagnosticId, currentTime); - assertEquals(currentTime, diagnosticAccumulator.dataSinceDate); - } - @Test public void createsDiagnosticStatisticsEvent() { DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); - long currentTime = System.currentTimeMillis(); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); - diagnosticAccumulator.start(diagnosticId, currentTime); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + long startDate = diagnosticAccumulator.dataSinceDate; DiagnosticEvent.Statistics diagnosticStatisticsEvent = diagnosticAccumulator.createEventAndReset(10, 15, 20); assertSame(diagnosticId, diagnosticStatisticsEvent.id); assertEquals(10, diagnosticStatisticsEvent.droppedEvents); assertEquals(15, diagnosticStatisticsEvent.deduplicatedUsers); assertEquals(20, diagnosticStatisticsEvent.eventsInQueue); - assertEquals(currentTime, diagnosticStatisticsEvent.dataSinceDate); + assertEquals(startDate, diagnosticStatisticsEvent.dataSinceDate); } @Test public void resetsDataSinceDate() throws InterruptedException { - long currentTime = System.currentTimeMillis(); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(); - diagnosticAccumulator.start(null, currentTime); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId("SDK_KEY")); + long startDate = diagnosticAccumulator.dataSinceDate; Thread.sleep(2); diagnosticAccumulator.createEventAndReset(0, 0, 0); - assertNotEquals(currentTime, diagnosticAccumulator.dataSinceDate); + assertNotEquals(startDate, diagnosticAccumulator.dataSinceDate); } } diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index 70eb2e2cc..eabb778d5 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -46,7 +46,6 @@ public void testDefaultDiagnosticConfiguration() { expected.addProperty("customEventsURI", false); expected.addProperty("customStreamURI", false); expected.addProperty("diagnosticRecordingIntervalMillis", 900_000); - expected.addProperty("eventReportingDisabled", false); expected.addProperty("eventsCapacity", 10_000); expected.addProperty("eventsFlushIntervalMillis",5_000); expected.addProperty("featureStore", "InMemoryFeatureStoreFactory"); @@ -106,7 +105,6 @@ public void testCustomDiagnosticConfiguration() { expected.addProperty("customEventsURI", true); expected.addProperty("customStreamURI", true); expected.addProperty("diagnosticRecordingIntervalMillis", 1_800_000); - expected.addProperty("eventReportingDisabled", true); expected.addProperty("eventsCapacity", 20_000); expected.addProperty("eventsFlushIntervalMillis",10_000); expected.addProperty("featureStore", "RedisFeatureStoreBuilder"); diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index b585536c9..be53b301a 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -7,6 +7,8 @@ import com.google.common.collect.Iterables; import com.launchdarkly.client.value.LDValue; +import junit.framework.AssertionFailedError; + import org.easymock.Capture; import org.easymock.EasyMock; import org.easymock.EasyMockSupport; @@ -21,6 +23,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static com.launchdarkly.client.TestUtil.failedUpdateProcessor; import static com.launchdarkly.client.TestUtil.flagWithValue; import static com.launchdarkly.client.TestUtil.initedFeatureStore; import static com.launchdarkly.client.TestUtil.specificFeatureStore; @@ -28,21 +31,26 @@ 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.capture; +import static org.easymock.EasyMock.eq; import static org.easymock.EasyMock.expect; import static org.easymock.EasyMock.expectLastCall; +import static org.easymock.EasyMock.isA; +import static org.easymock.EasyMock.isNull; import static org.easymock.EasyMock.replay; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import junit.framework.AssertionFailedError; - /** * See also LDClientEvaluationTest, etc. This file contains mostly tests for the startup logic. */ @SuppressWarnings("javadoc") public class LDClientTest extends EasyMockSupport { + private final static String SDK_KEY = "SDK_KEY"; + private UpdateProcessor updateProcessor; private EventProcessor eventProcessor; private Future initFuture; @@ -76,7 +84,7 @@ public void constructorWithConfigThrowsExceptionForNullSdkKey() throws Exception @Test public void constructorThrowsExceptionForNullConfig() throws Exception { - try (LDClient client = new LDClient("SDK_KEY", null)) { + try (LDClient client = new LDClient(SDK_KEY, null)) { fail("expected exception"); } catch (NullPointerException e) { assertEquals("config must not be null", e.getMessage()); @@ -91,7 +99,7 @@ public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception .startWaitMillis(0) .sendEvents(true) .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { + try (LDClient client = new LDClient(SDK_KEY, config)) { assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); } } @@ -104,7 +112,7 @@ public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException .startWaitMillis(0) .sendEvents(false) .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { + try (LDClient client = new LDClient(SDK_KEY, config)) { assertEquals(EventProcessor.NullEventProcessor.class, client.eventProcessor.getClass()); } } @@ -116,7 +124,7 @@ public void streamingClientHasStreamProcessor() throws Exception { .streamURI(URI.create("http://fake")) .startWaitMillis(0) .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { + try (LDClient client = new LDClient(SDK_KEY, config)) { assertEquals(StreamProcessor.class, client.updateProcessor.getClass()); } } @@ -128,11 +136,85 @@ public void pollingClientHasPollingProcessor() throws IOException { .baseURI(URI.create("http://fake")) .startWaitMillis(0) .build(); - try (LDClient client = new LDClient("SDK_KEY", config)) { + try (LDClient client = new LDClient(SDK_KEY, config)) { assertEquals(PollingProcessor.class, client.updateProcessor.getClass()); } } + @Test + public void sameDiagnosticAccumulatorPassedToFactoriesWhenSupported() throws IOException { + EventProcessorFactoryWithDiagnostics mockEventProcessorFactory = createStrictMock(EventProcessorFactoryWithDiagnostics.class); + UpdateProcessorFactoryWithDiagnostics mockUpdateProcessorFactory = createStrictMock(UpdateProcessorFactoryWithDiagnostics.class); + + LDConfig config = new LDConfig.Builder() + .stream(false) + .baseURI(URI.create("http://fake")) + .startWaitMillis(0) + .eventProcessorFactory(mockEventProcessorFactory) + .updateProcessorFactory(mockUpdateProcessorFactory) + .build(); + + Capture capturedEventAccumulator = Capture.newInstance(); + Capture capturedUpdateAccumulator = Capture.newInstance(); + expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class), capture(capturedEventAccumulator))).andReturn(niceMock(EventProcessor.class)); + expect(mockUpdateProcessorFactory.createUpdateProcessor(eq(SDK_KEY), isA(LDConfig.class), isA(FeatureStore.class), capture(capturedUpdateAccumulator))).andReturn(failedUpdateProcessor()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + assertNotNull(capturedEventAccumulator.getValue()); + assertEquals(capturedEventAccumulator.getValue(), capturedUpdateAccumulator.getValue()); + } + } + + @Test + public void nullDiagnosticAccumulatorPassedToFactoriesWhenOptedOut() throws IOException { + EventProcessorFactoryWithDiagnostics mockEventProcessorFactory = createStrictMock(EventProcessorFactoryWithDiagnostics.class); + UpdateProcessorFactoryWithDiagnostics mockUpdateProcessorFactory = createStrictMock(UpdateProcessorFactoryWithDiagnostics.class); + + LDConfig config = new LDConfig.Builder() + .stream(false) + .baseURI(URI.create("http://fake")) + .startWaitMillis(0) + .eventProcessorFactory(mockEventProcessorFactory) + .updateProcessorFactory(mockUpdateProcessorFactory) + .diagnosticOptOut(true) + .build(); + + expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class), isNull(DiagnosticAccumulator.class))).andReturn(niceMock(EventProcessor.class)); + expect(mockUpdateProcessorFactory.createUpdateProcessor(eq(SDK_KEY), isA(LDConfig.class), isA(FeatureStore.class), isNull(DiagnosticAccumulator.class))).andReturn(failedUpdateProcessor()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + } + } + + @Test + public void nullDiagnosticAccumulatorPassedToUpdateFactoryWhenEventProcessorDoesNotSupportDiagnostics() throws IOException { + EventProcessorFactory mockEventProcessorFactory = createStrictMock(EventProcessorFactory.class); + UpdateProcessorFactoryWithDiagnostics mockUpdateProcessorFactory = createStrictMock(UpdateProcessorFactoryWithDiagnostics.class); + + LDConfig config = new LDConfig.Builder() + .stream(false) + .baseURI(URI.create("http://fake")) + .startWaitMillis(0) + .eventProcessorFactory(mockEventProcessorFactory) + .updateProcessorFactory(mockUpdateProcessorFactory) + .build(); + + expect(mockEventProcessorFactory.createEventProcessor(eq(SDK_KEY), isA(LDConfig.class))).andReturn(niceMock(EventProcessor.class)); + expect(mockUpdateProcessorFactory.createUpdateProcessor(eq(SDK_KEY), isA(LDConfig.class), isA(FeatureStore.class), isNull(DiagnosticAccumulator.class))).andReturn(failedUpdateProcessor()); + + replayAll(); + + try (LDClient client = new LDClient(SDK_KEY, config)) { + verifyAll(); + } + } + @Test public void noWaitForUpdateProcessorIfWaitMillisIsZero() throws Exception { LDConfig.Builder config = new LDConfig.Builder() @@ -297,7 +379,7 @@ public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { .updateProcessorFactory(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) .featureStoreFactory(specificFeatureStore(store)) .sendEvents(false); - client = new LDClient("SDK_KEY", config.build()); + client = new LDClient(SDK_KEY, config.build()); Map, Map> dataMap = captureData.getValue(); assertEquals(2, dataMap.size()); @@ -342,7 +424,7 @@ private void expectEventsSent(int count) { private LDClientInterface createMockClient(LDConfig.Builder config) { config.updateProcessorFactory(TestUtil.specificUpdateProcessor(updateProcessor)); config.eventProcessorFactory(TestUtil.specificEventProcessor(eventProcessor)); - return new LDClient("SDK_KEY", config.build()); + return new LDClient(SDK_KEY, config.build()); } private static Map, Map> DEPENDENCY_ORDERING_TEST_DATA = diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 586c80bba..42bcd535c 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -351,7 +351,7 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { .build(); try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + mockRequestor, featureStore, null, null)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -375,7 +375,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { .build(); try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + mockRequestor, featureStore, null, null)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -398,7 +398,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .build(); try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + mockRequestor, featureStore, null, null)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -457,7 +457,7 @@ private void testRecoverableHttpError(int status) throws Exception { } private StreamProcessor createStreamProcessor(String sdkKey, LDConfig config) { - return new StreamProcessor(sdkKey, config, mockRequestor, featureStore, new StubEventSourceCreator()); + return new StreamProcessor(sdkKey, config, mockRequestor, featureStore, new StubEventSourceCreator(), null); } private String featureJson(String key, int version) { From d5532069c0a20539c6909dae57206062f2c4660e Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 22 Nov 2019 23:44:08 +0000 Subject: [PATCH 208/327] Diagnostic stream inits. (#151) --- .../client/DiagnosticAccumulator.java | 18 ++++- .../launchdarkly/client/DiagnosticEvent.java | 18 ++++- .../launchdarkly/client/StreamProcessor.java | 14 +++- .../client/DiagnosticEventTest.java | 18 +++-- .../client/StreamProcessorTest.java | 66 +++++++++++++++++-- 5 files changed, 121 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java index 96068bc1a..9f96e16d5 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java @@ -1,19 +1,35 @@ package com.launchdarkly.client; +import java.util.ArrayList; +import java.util.List; + class DiagnosticAccumulator { final DiagnosticId diagnosticId; volatile long dataSinceDate; + private final Object streamInitsLock = new Object(); + private ArrayList streamInits = new ArrayList<>(); DiagnosticAccumulator(DiagnosticId diagnosticId) { this.diagnosticId = diagnosticId; this.dataSinceDate = System.currentTimeMillis(); } + void recordStreamInit(long timestamp, long durationMillis, boolean failed) { + synchronized (streamInitsLock) { + streamInits.add(new DiagnosticEvent.StreamInit(timestamp, durationMillis, failed)); + } + } + DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long deduplicatedUsers, long eventsInQueue) { long currentTime = System.currentTimeMillis(); + List eventInits; + synchronized (streamInitsLock) { + eventInits = streamInits; + streamInits = new ArrayList<>(); + } DiagnosticEvent.Statistics res = new DiagnosticEvent.Statistics(currentTime, diagnosticId, dataSinceDate, droppedEvents, - deduplicatedUsers, eventsInQueue); + deduplicatedUsers, eventsInQueue, eventInits); dataSinceDate = currentTime; return res; } diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 284365723..2204ef7bb 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import java.util.List; + class DiagnosticEvent { final String kind; @@ -12,20 +14,34 @@ class DiagnosticEvent { this.id = id; } + static class StreamInit { + long timestamp; + long durationMillis; + boolean failed; + + public StreamInit(long timestamp, long durationMillis, boolean failed) { + this.timestamp = timestamp; + this.durationMillis = durationMillis; + this.failed = failed; + } + } + static class Statistics extends DiagnosticEvent { final long dataSinceDate; final long droppedEvents; final long deduplicatedUsers; final long eventsInQueue; + final List streamInits; Statistics(long creationDate, DiagnosticId id, long dataSinceDate, long droppedEvents, long deduplicatedUsers, - long eventsInQueue) { + long eventsInQueue, List streamInits) { super("diagnostic", creationDate, id); this.dataSinceDate = dataSinceDate; this.droppedEvents = droppedEvents; this.deduplicatedUsers = deduplicatedUsers; this.eventsInQueue = eventsInQueue; + this.streamInits = streamInits; } } diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 477c37a93..8630f2e6b 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -44,6 +44,7 @@ final class StreamProcessor implements UpdateProcessor { private final EventSourceCreator eventSourceCreator; private volatile EventSource es; private final AtomicBoolean initialized = new AtomicBoolean(false); + private volatile long esStarted = 0; ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing @@ -65,6 +66,7 @@ private ConnectionErrorHandler createDefaultConnectionErrorHandler() { return new ConnectionErrorHandler() { @Override public Action onConnectionError(Throwable t) { + recordStreamInit(true); if (t instanceof UnsuccessfulResponseException) { int status = ((UnsuccessfulResponseException)t).getCode(); logger.error(httpErrorMessage(status, "streaming connection", "will retry")); @@ -72,6 +74,7 @@ public Action onConnectionError(Throwable t) { return Action.SHUTDOWN; } } + esStarted = System.currentTimeMillis(); return Action.PROCEED; } }; @@ -111,6 +114,8 @@ public void onMessage(String name, MessageEvent event) throws Exception { Gson gson = new Gson(); switch (name) { case PUT: { + recordStreamInit(false); + esStarted = 0; PutData putData = gson.fromJson(event.getData(), PutData.class); store.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); if (!initialized.getAndSet(true)) { @@ -195,10 +200,17 @@ public void onError(Throwable throwable) { URI.create(config.streamURI.toASCIIString() + "/all"), wrappedConnectionErrorHandler, headers); + esStarted = System.currentTimeMillis(); es.start(); return initFuture; } - + + private void recordStreamInit(boolean failed) { + if (diagnosticAccumulator != null && esStarted != 0) { + diagnosticAccumulator.recordStreamInit(esStarted, System.currentTimeMillis() - esStarted, failed); + } + } + @Override public void close() throws IOException { logger.info("Closing LaunchDarkly StreamProcessor"); diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index eabb778d5..4f6155ab2 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -1,27 +1,31 @@ package com.launchdarkly.client; import com.google.gson.Gson; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; + import org.junit.Test; import java.net.URI; +import java.util.Collections; +import java.util.List; import java.util.UUID; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; public class DiagnosticEventTest { private static Gson gson = new Gson(); + private static List testStreamInits = Collections.singletonList(new DiagnosticEvent.StreamInit(1500, 100, true)); @SuppressWarnings("ResultOfMethodCallIgnored") @Test public void testSerialization() { DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); - DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = new DiagnosticEvent.Statistics(2000, diagnosticId, 1000, 1, 2, 3, testStreamInits); JsonObject jsonObject = gson.toJsonTree(diagnosticStatisticsEvent).getAsJsonObject(); - assertEquals(7, jsonObject.size()); + assertEquals(8, jsonObject.size()); assertEquals("diagnostic", diagnosticStatisticsEvent.kind); assertEquals(2000, jsonObject.getAsJsonPrimitive("creationDate").getAsLong()); JsonObject idObject = jsonObject.getAsJsonObject("id"); @@ -32,6 +36,12 @@ public void testSerialization() { assertEquals(1, jsonObject.getAsJsonPrimitive("droppedEvents").getAsLong()); assertEquals(2, jsonObject.getAsJsonPrimitive("deduplicatedUsers").getAsLong()); assertEquals(3, jsonObject.getAsJsonPrimitive("eventsInQueue").getAsLong()); + JsonArray initsJson = jsonObject.getAsJsonArray("streamInits"); + assertEquals(1, initsJson.size()); + JsonObject initJson = initsJson.get(0).getAsJsonObject(); + assertEquals(1500, initJson.getAsJsonPrimitive("timestamp").getAsInt()); + assertEquals(100, initJson.getAsJsonPrimitive("durationMillis").getAsInt()); + assertTrue(initJson.getAsJsonPrimitive("failed").getAsBoolean()); } @Test diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 42bcd535c..a39f0339e 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -21,12 +21,19 @@ import javax.net.ssl.SSLHandshakeException; +import okhttp3.Headers; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockWebServer; + import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.expect; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -34,10 +41,6 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import okhttp3.Headers; -import okhttp3.HttpUrl; -import okhttp3.mockwebserver.MockWebServer; - @SuppressWarnings("javadoc") public class StreamProcessorTest extends EasyMockSupport { @@ -306,6 +309,53 @@ public void streamWillReconnectAfterGeneralIOException() throws Exception { assertEquals(ConnectionErrorHandler.Action.PROCEED, action); } + @Test + public void streamInitDiagnosticRecordedOnOpen() throws Exception { + LDConfig config = configBuilder.build(); + DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); + long startTime = System.currentTimeMillis(); + createStreamProcessor(SDK_KEY, config, acc).start(); + eventHandler.onMessage("put", emptyPutEvent()); + long timeAfterOpen = System.currentTimeMillis(); + DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0, 0); + assertEquals(1, event.streamInits.size()); + DiagnosticEvent.StreamInit init = event.streamInits.get(0); + assertFalse(init.failed); + assertThat(init.timestamp, greaterThanOrEqualTo(startTime)); + assertThat(init.timestamp, lessThanOrEqualTo(timeAfterOpen)); + assertThat(init.durationMillis, lessThanOrEqualTo(timeAfterOpen - startTime)); + } + + @Test + public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { + LDConfig config = configBuilder.build(); + DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); + long startTime = System.currentTimeMillis(); + createStreamProcessor(SDK_KEY, config, acc).start(); + errorHandler.onConnectionError(new IOException()); + long timeAfterOpen = System.currentTimeMillis(); + DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0, 0); + assertEquals(1, event.streamInits.size()); + DiagnosticEvent.StreamInit init = event.streamInits.get(0); + assertTrue(init.failed); + assertThat(init.timestamp, greaterThanOrEqualTo(startTime)); + assertThat(init.timestamp, lessThanOrEqualTo(timeAfterOpen)); + assertThat(init.durationMillis, lessThanOrEqualTo(timeAfterOpen - startTime)); + } + + @Test + public void streamInitDiagnosticNotRecordedOnErrorAfterInit() throws Exception { + LDConfig config = configBuilder.build(); + DiagnosticAccumulator acc = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); + createStreamProcessor(SDK_KEY, config, acc).start(); + eventHandler.onMessage("put", emptyPutEvent()); + // Drop first stream init from stream open + acc.createEventAndReset(0, 0, 0); + errorHandler.onConnectionError(new IOException()); + DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0, 0); + assertEquals(0, event.streamInits.size()); + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); @@ -457,9 +507,13 @@ private void testRecoverableHttpError(int status) throws Exception { } private StreamProcessor createStreamProcessor(String sdkKey, LDConfig config) { - return new StreamProcessor(sdkKey, config, mockRequestor, featureStore, new StubEventSourceCreator(), null); + return createStreamProcessor(sdkKey, config, null); } - + + private StreamProcessor createStreamProcessor(String sdkKey, LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { + return new StreamProcessor(sdkKey, config, mockRequestor, featureStore, new StubEventSourceCreator(), diagnosticAccumulator); + } + private String featureJson(String key, int version) { return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"on\":true}"; } From e243cd0499aa2e520ad17aa190ce66b931628714 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 6 Dec 2019 21:51:14 +0000 Subject: [PATCH 209/327] [ch57100] Deprecate LDCountryCode and LDUser.Builder methods with LDCountryCode (#152) --- .../launchdarkly/client/LDCountryCode.java | 2 + .../java/com/launchdarkly/client/LDUser.java | 41 ++++++++++++------- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDCountryCode.java b/src/main/java/com/launchdarkly/client/LDCountryCode.java index b8aa9a758..ad344e4dc 100644 --- a/src/main/java/com/launchdarkly/client/LDCountryCode.java +++ b/src/main/java/com/launchdarkly/client/LDCountryCode.java @@ -97,6 +97,8 @@ * * @author Takahiko Kawasaki */ +@SuppressWarnings({"deprecation", "DeprecatedIsStillUsed"}) +@Deprecated public enum LDCountryCode { /** diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index b222644be..d9473bec2 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -63,7 +63,7 @@ protected LDUser(Builder builder) { } this.key = LDValue.of(builder.key); this.ip = LDValue.of(builder.ip); - this.country = builder.country == null ? LDValue.ofNull() : LDValue.of(builder.country.getAlpha2()); + this.country = LDValue.of(builder.country); this.secondary = LDValue.of(builder.secondary); this.firstName = LDValue.of(builder.firstName); this.lastName = LDValue.of(builder.lastName); @@ -317,7 +317,7 @@ public static class Builder { private String name; private String avatar; private Boolean anonymous; - private LDCountryCode country; + private String country; private Map custom; private Set privateAttrNames; @@ -345,7 +345,7 @@ public Builder(LDUser user) { this.name = user.getName().stringValue(); this.avatar = user.getAvatar().stringValue(); this.anonymous = user.getAnonymous().isNull() ? null : user.getAnonymous().booleanValue(); - this.country = user.getCountry().isNull() ? null : LDCountryCode.valueOf(user.getCountry().stringValue()); + this.country = user.getCountry().stringValue(); this.custom = user.custom == null ? null : new HashMap<>(user.custom); this.privateAttrNames = user.privateAttributeNames == null ? null : new HashSet<>(user.privateAttributeNames); } @@ -399,17 +399,19 @@ public Builder privateSecondary(String s) { /** * Set the country for a user. *

    - * The country should be a valid ISO 3166-1 + * In the current SDK version the country should be a valid ISO 3166-1 * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name. - * If that fails, a warning will be logged, and the country will not be set. + * If that fails, a warning will be logged, and the country will not be set. In the next major release, this validation + * will be removed, and the country field will be treated as a normal string. * * @param s the country for the user * @return the builder */ + @SuppressWarnings("deprecation") public Builder country(String s) { - country = LDCountryCode.getByCode(s, false); + LDCountryCode countryCode = LDCountryCode.getByCode(s, false); - if (country == null) { + if (countryCode == null) { List codes = LDCountryCode.findByName("^" + Pattern.quote(s) + ".*"); if (codes.isEmpty()) { @@ -418,26 +420,29 @@ public Builder country(String s) { // See if any of the codes is an exact match for (LDCountryCode c : codes) { if (c.getName().equals(s)) { - country = c; + country = c.getAlpha2(); return this; } } logger.warn("Ambiguous country. Provided code matches multiple countries: " + s); - country = codes.get(0); + country = codes.get(0).getAlpha2(); } else { - country = codes.get(0); + country = codes.get(0).getAlpha2(); } - + } else { + country = countryCode.getAlpha2(); } + return this; } /** * Set the country for a user, and ensures that the country attribute will not be sent back to LaunchDarkly. *

    - * The country should be a valid ISO 3166-1 + * In the current SDK version the country should be a valid ISO 3166-1 * alpha-2 or alpha-3 code. If it is not a valid ISO-3166-1 code, an attempt will be made to look up the country by its name. - * If that fails, a warning will be logged, and the country will not be set. + * If that fails, a warning will be logged, and the country will not be set. In the next major release, this validation + * will be removed, and the country field will be treated as a normal string. * * @param s the country for the user * @return the builder @@ -452,9 +457,13 @@ public Builder privateCountry(String s) { * * @param country the country for the user * @return the builder + * @deprecated As of version 4.10.0. In the next major release the SDK will no longer include the + * LDCountryCode class. Applications should use {@link #country(String)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public Builder country(LDCountryCode country) { - this.country = country; + this.country = country == null ? null : country.getAlpha2(); return this; } @@ -463,7 +472,11 @@ public Builder country(LDCountryCode country) { * * @param country the country for the user * @return the builder + * @deprecated As of version 4.10.0. In the next major release the SDK will no longer include the + * LDCountryCode class. Applications should use {@link #privateCountry(String)} instead. */ + @SuppressWarnings("deprecation") + @Deprecated public Builder privateCountry(LDCountryCode country) { addPrivate("country"); return country(country); From 3a800a8fb657093ece84803668cacb732218ea9f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 10 Dec 2019 15:09:15 -0800 Subject: [PATCH 210/327] use CircleCI for Windows build (#154) * add CircleCI Windows job, simplify CI config * CI fix * CI fixes * use --no-daemon on Windows --- .circleci/config.yml | 156 +++++++++++++++++++++++++++---------------- azure-pipelines.yml | 26 -------- 2 files changed, 97 insertions(+), 85 deletions(-) delete mode 100644 azure-pipelines.yml diff --git a/.circleci/config.yml b/.circleci/config.yml index c0a066c46..d1ac6114f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,26 +1,40 @@ -version: 2 +version: 2.1 -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 +orbs: + win: circleci/windows@1.0.0 + +workflows: + test: + jobs: + - build-linux + - test-linux: + name: Java 8 - Linux - OpenJDK + docker-image: circleci/openjdk:8 + requires: + - build-linux + - test-linux: + name: Java 9 - Linux - OpenJDK + docker-image: circleci/openjdk:9 + requires: + - build-linux + - test-linux: + name: Java 10 - Linux - OpenJDK + docker-image: circleci/openjdk:10 + requires: + - build-linux + - test-linux: + name: Java 11 - Linux - OpenJDK + docker-image: circleci/openjdk:11 + requires: + - build-linux + - packaging: + requires: + - build-linux + - build-test-windows: + name: Java 11 - Windows - OpenJDK jobs: - build: + build-linux: docker: - image: circleci/openjdk:8u131-jdk # To match the version pre-installed in Ubuntu 16 and used by Jenkins for releasing steps: @@ -33,26 +47,71 @@ jobs: 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 + + test-linux: + parameters: + docker-image: + type: string docker: - - image: circleci/openjdk:11 + - image: <> - image: redis + 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 + + build-test-windows: + executor: + name: win/vs2019 + shell: powershell.exe + steps: + - checkout + - run: + name: install OpenJDK + command: | + $ProgressPreference = "SilentlyContinue" # prevents console errors from CircleCI host + iwr -outf openjdk.msi https://developers.redhat.com/download-manager/file/java-11-openjdk-11.0.5.10-2.windows.redhat.x86_64.msi + Start-Process msiexec.exe -Wait -ArgumentList '/I openjdk.msi /quiet' + - run: + name: start Redis + command: | + $ProgressPreference = "SilentlyContinue" + 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 + Start-Sleep -s 5 + .\redis-cli ping + - run: + name: build and test + command: | + cp gradle.properties.example gradle.properties + .\gradlew.bat --no-daemon test # must use --no-daemon because CircleCI in Windows will hang if there's a daemon running + - run: + name: save test results + command: | + mkdir .\junit + cp build/test-results/test/*.xml junit + - store_test_results: + path: .\junit + - store_artifacts: + path: .\junit + packaging: docker: - image: circleci/openjdk:8 @@ -69,24 +128,3 @@ jobs: - run: name: run packaging tests command: cd packaging-test && make all - -workflows: - version: 2 - test: - jobs: - - build - - test-java8: - requires: - - build - - test-java9: - requires: - - build - - test-java10: - requires: - - build - - test-java11: - requires: - - build - - packaging: - requires: - - build diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 54a51ff98..000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,26 +0,0 @@ -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 16aaf85c7e4965e949d36504e46cc49aca0953f5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 11 Dec 2019 10:23:13 -0800 Subject: [PATCH 211/327] fail build on javadoc mistakes/omissions; deprecate some internal stuff (#157) --- .circleci/config.yml | 3 + build.gradle | 19 +++++ checkstyle.xml | 15 ++++ .../launchdarkly/client/EvaluationDetail.java | 1 + .../launchdarkly/client/EvaluationReason.java | 16 ++++ .../java/com/launchdarkly/client/Event.java | 85 +++++++++++++++++++ .../client/LDClientInterface.java | 8 ++ .../com/launchdarkly/client/SegmentRule.java | 19 +++++ .../launchdarkly/client/TestFeatureStore.java | 4 + .../launchdarkly/client/UpdateProcessor.java | 10 +++ .../launchdarkly/client/VersionedData.java | 12 +++ .../client/utils/FeatureStoreHelpers.java | 7 ++ 12 files changed, 199 insertions(+) create mode 100644 checkstyle.xml diff --git a/.circleci/config.yml b/.circleci/config.yml index d1ac6114f..554291282 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -122,6 +122,9 @@ jobs: - attach_workspace: at: build - run: cat gradle.properties.example >>gradle.properties + - run: + name: checkstyle/javadoc + command: ./gradlew checkstyleMain - run: name: build all SDK jars command: ./gradlew publishToMavenLocal -P LD_SKIP_SIGNING=1 diff --git a/build.gradle b/build.gradle index 6d80f9a7f..da1c4fd23 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'java' +apply plugin: 'checkstyle' apply plugin: 'maven-publish' apply plugin: 'org.ajoberstar.github-pages' apply plugin: 'signing' @@ -94,6 +95,10 @@ buildscript { } } +checkstyle { + configFile file("${project.rootDir}/checkstyle.xml") +} + jar { baseName = sdkBaseName // thin classifier means that the non-shaded non-fat jar is still available @@ -169,6 +174,20 @@ task javadocJar(type: Jar, dependsOn: javadoc) { from javadoc.destinationDir } + +// Force the Javadoc build to fail if there are any Javadoc warnings. See: https://discuss.gradle.org/t/javadoc-fail-on-warning/18141/3 +if (JavaVersion.current().isJava8Compatible()) { + tasks.withType(Javadoc) { + // The '-quiet' as second argument is actually a hack, + // since the one paramater addStringOption doesn't seem to + // work, we extra add '-quiet', which is added anyway by + // gradle. See https://github.com/gradle/gradle/issues/2354 + // See JDK-8200363 (https://bugs.openjdk.java.net/browse/JDK-8200363) + // for information about the -Xwerror option. + options.addStringOption('Xwerror', '-quiet') + } +} + githubPages { repoUri = 'https://github.com/launchdarkly/java-server-sdk.git' pages { diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 000000000..0b201f9c0 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/EvaluationDetail.java b/src/main/java/com/launchdarkly/client/EvaluationDetail.java index a1098b025..f9eaf2a65 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationDetail.java +++ b/src/main/java/com/launchdarkly/client/EvaluationDetail.java @@ -31,6 +31,7 @@ public EvaluationDetail(EvaluationReason reason, Integer variationIndex, T value /** * Factory method for an arbitrary value. * + * @param the type of the value * @param value a value of the desired type * @param variationIndex an optional variation index * @param reason an {@link EvaluationReason} (should not be null) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index eade55460..c4cf9492f 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -197,10 +197,18 @@ private RuleMatch(int ruleIndex, String ruleId) { this.ruleId = ruleId; } + /** + * The index of the rule that was matched (0 for the first rule in the feature flag). + * @return the rule index + */ public int getRuleIndex() { return ruleIndex; } + /** + * A unique string identifier for the matched rule, which will not change if other rules are added or deleted. + * @return the rule identifier + */ public String getRuleId() { return ruleId; } @@ -238,6 +246,10 @@ private PrerequisiteFailed(String prerequisiteKey) { this.prerequisiteKey = checkNotNull(prerequisiteKey); } + /** + * The key of the prerequisite flag that did not return the desired variation. + * @return the prerequisite flag key + */ public String getPrerequisiteKey() { return prerequisiteKey; } @@ -286,6 +298,10 @@ private Error(ErrorKind errorKind) { this.errorKind = errorKind; } + /** + * An enumeration value indicating the general category of error. + * @return the error kind + */ public ErrorKind getErrorKind() { return errorKind; } diff --git a/src/main/java/com/launchdarkly/client/Event.java b/src/main/java/com/launchdarkly/client/Event.java index 6f9b2121a..40ff0053c 100644 --- a/src/main/java/com/launchdarkly/client/Event.java +++ b/src/main/java/com/launchdarkly/client/Event.java @@ -10,17 +10,31 @@ public class Event { final long creationDate; final LDUser user; + /** + * Base event constructor. + * @param creationDate the timetamp in milliseconds + * @param user the user associated with the event + */ public Event(long creationDate, LDUser user) { this.creationDate = creationDate; this.user = user; } + /** + * A custom event created with {@link LDClientInterface#track(String, LDUser)} or one of its overloads. + */ public static final class Custom extends Event { final String key; final LDValue data; final Double metricValue; /** + * Constructs a custom event. + * @param timestamp the timestamp in milliseconds + * @param key the event key + * @param user the user associated with the event + * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) + * @param metricValue custom metric value if any * @since 4.8.0 */ public Custom(long timestamp, String key, LDUser user, LDValue data, Double metricValue) { @@ -30,24 +44,51 @@ public Custom(long timestamp, String key, LDUser user, LDValue data, Double metr this.metricValue = metricValue; } + /** + * Deprecated constructor. + * @param timestamp the timestamp in milliseconds + * @param key the event key + * @param user the user associated with the event + * @param data custom data if any (null is the same as {@link LDValue#ofNull()}) + * @deprecated + */ @Deprecated public Custom(long timestamp, String key, LDUser user, JsonElement data) { this(timestamp, key, user, LDValue.unsafeFromJsonElement(data), null); } } + /** + * An event created with {@link LDClientInterface#identify(LDUser)}. + */ public static final class Identify extends Event { + /** + * Constructs an identify event. + * @param timestamp the timestamp in milliseconds + * @param user the user associated with the event + */ public Identify(long timestamp, LDUser user) { super(timestamp, user); } } + /** + * An event created internally by the SDK to hold user data that may be referenced by multiple events. + */ public static final class Index extends Event { + /** + * Constructs an index event. + * @param timestamp the timestamp in milliseconds + * @param user the user associated with the event + */ public Index(long timestamp, LDUser user) { super(timestamp, user); } } + /** + * An event generated by a feature flag evaluation. + */ public static final class FeatureRequest extends Event { final String key; final Integer variation; @@ -61,6 +102,19 @@ public static final class FeatureRequest extends Event { final boolean debug; /** + * Constructs a feature request event. + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param user the user associated with the event + * @param version the flag version, or null if the flag was not found + * @param variation the result variation, or null if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled + * @param debug true if this is a debugging event * @since 4.8.0 */ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, Integer variation, LDValue value, @@ -78,6 +132,21 @@ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, this.debug = debug; } + /** + * Deprecated constructor. + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param user the user associated with the event + * @param version the flag version, or null if the flag was not found + * @param variation the result variation, or null if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled + * @param debug true if this is a debugging event + * @deprecated + */ @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) { @@ -85,6 +154,22 @@ public FeatureRequest(long timestamp, String key, LDUser user, Integer version, null, prereqOf, trackEvents, debugEventsUntilDate, debug); } + /** + * Deprecated constructor. + * @param timestamp the timestamp in milliseconds + * @param key the flag key + * @param user the user associated with the event + * @param version the flag version, or null if the flag was not found + * @param variation the result variation, or null if there was an error + * @param value the result value + * @param defaultVal the default value passed by the application + * @param reason the evaluation reason, if it is to be included in the event + * @param prereqOf if this flag was evaluated as a prerequisite, this is the key of the flag that referenced it + * @param trackEvents true if full event tracking is turned on for this flag + * @param debugEventsUntilDate if non-null, the time until which event debugging should be enabled + * @param debug true if this is a debugging event + * @deprecated + */ @Deprecated 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) { diff --git a/src/main/java/com/launchdarkly/client/LDClientInterface.java b/src/main/java/com/launchdarkly/client/LDClientInterface.java index d8e2154b1..80db0168b 100644 --- a/src/main/java/com/launchdarkly/client/LDClientInterface.java +++ b/src/main/java/com/launchdarkly/client/LDClientInterface.java @@ -11,6 +11,10 @@ * This interface defines the public methods of {@link LDClient}. */ public interface LDClientInterface extends Closeable { + /** + * Tests whether the client is ready to be used. + * @return true if the client is ready, or false if it is still initializing + */ boolean initialized(); /** @@ -302,5 +306,9 @@ public interface LDClientInterface extends Closeable { */ String secureModeHash(LDUser user); + /** + * The current version string of the SDK. + * @return a string in Semantic Versioning 2.0.0 format + */ String version(); } diff --git a/src/main/java/com/launchdarkly/client/SegmentRule.java b/src/main/java/com/launchdarkly/client/SegmentRule.java index 4498eb7d6..79b3df68f 100644 --- a/src/main/java/com/launchdarkly/client/SegmentRule.java +++ b/src/main/java/com/launchdarkly/client/SegmentRule.java @@ -2,17 +2,36 @@ import java.util.List; +/** + * Internal data model class. + * + * @deprecated This class was made public in error and will be removed in a future release. It is used internally by the SDK. + */ +@Deprecated public class SegmentRule { private final List clauses; private final Integer weight; private final String bucketBy; + /** + * Used internally to construct an instance. + * @param clauses the clauses in the rule + * @param weight the rollout weight + * @param bucketBy the attribute for computing a rollout + */ public SegmentRule(List clauses, Integer weight, String bucketBy) { this.clauses = clauses; this.weight = weight; this.bucketBy = bucketBy; } + /** + * Used internally to match a user against a segment. + * @param user the user to match + * @param segmentKey the segment key + * @param salt the segment's salt string + * @return true if the user matches + */ public boolean matchUser(LDUser user, String segmentKey, String salt) { for (Clause c: clauses) { if (!c.matchesUserNoSegments(user)) { diff --git a/src/main/java/com/launchdarkly/client/TestFeatureStore.java b/src/main/java/com/launchdarkly/client/TestFeatureStore.java index daf3cf000..e2725147d 100644 --- a/src/main/java/com/launchdarkly/client/TestFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/TestFeatureStore.java @@ -123,6 +123,10 @@ public boolean initialized() { return initializedForTests; } + /** + * Sets the initialization status that the feature store will report to the SDK + * @param value true if the store should show as initialized + */ public void setInitialized(boolean value) { initializedForTests = value; } diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessor.java b/src/main/java/com/launchdarkly/client/UpdateProcessor.java index 6d959a032..15cc7231e 100644 --- a/src/main/java/com/launchdarkly/client/UpdateProcessor.java +++ b/src/main/java/com/launchdarkly/client/UpdateProcessor.java @@ -24,8 +24,18 @@ public interface UpdateProcessor extends Closeable { */ boolean initialized(); + /** + * Tells the component to shut down and release any resources it is using. + * @throws IOException if there is an error while closing + */ void close() throws IOException; + /** + * An implementation of {@link UpdateProcessor} that does nothing. + * + * @deprecated Use {@link Components#nullUpdateProcessor()} instead of referring to this implementation class directly. + */ + @Deprecated static final class NullUpdateProcessor implements UpdateProcessor { @Override public Future start() { diff --git a/src/main/java/com/launchdarkly/client/VersionedData.java b/src/main/java/com/launchdarkly/client/VersionedData.java index 6e01b9997..98bd19c34 100644 --- a/src/main/java/com/launchdarkly/client/VersionedData.java +++ b/src/main/java/com/launchdarkly/client/VersionedData.java @@ -5,7 +5,19 @@ * @since 3.0.0 */ public interface VersionedData { + /** + * The key for this item, unique within the namespace of each {@link VersionedDataKind}. + * @return the key + */ String getKey(); + /** + * The version number for this item. + * @return the version number + */ int getVersion(); + /** + * True if this is a placeholder for a deleted item. + * @return true if deleted + */ boolean isDeleted(); } diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java index 871838899..6fefb8e62 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java @@ -44,8 +44,15 @@ public static String marshalJson(VersionedData item) { return gson.toJson(item); } + /** + * Thrown by {@link FeatureStoreHelpers#unmarshalJson(VersionedDataKind, String)} for a deserialization error. + */ @SuppressWarnings("serial") public static class UnmarshalException extends RuntimeException { + /** + * Constructs an instance. + * @param cause the underlying exception + */ public UnmarshalException(Throwable cause) { super(cause); } From 7d41d9eca88394a80a790d5529a787c14e381657 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 11 Dec 2019 11:22:47 -0800 Subject: [PATCH 212/327] use precomputed EvaluationReason instances instead of creating new ones (#156) --- .../launchdarkly/client/EvaluationReason.java | 18 ++++++- .../com/launchdarkly/client/FeatureFlag.java | 28 +++++++++-- .../client/FeatureFlagBuilder.java | 4 +- .../com/launchdarkly/client/JsonHelpers.java | 50 +++++++++++++++++++ .../com/launchdarkly/client/Prerequisite.java | 11 ++++ .../java/com/launchdarkly/client/Rule.java | 13 ++++- .../client/EvaluationReasonTest.java | 12 +++++ .../launchdarkly/client/FeatureFlagTest.java | 38 ++++++++++++++ .../client/FlagModelDeserializationTest.java | 33 ++++++++++++ 9 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/JsonHelpers.java create mode 100644 src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index c4cf9492f..f6c91bee8 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -83,6 +83,14 @@ public static enum ErrorKind { EXCEPTION } + // static instances to avoid repeatedly allocating reasons for the same errors + private static final Error ERROR_CLIENT_NOT_READY = new Error(ErrorKind.CLIENT_NOT_READY); + private static final Error ERROR_FLAG_NOT_FOUND = new Error(ErrorKind.FLAG_NOT_FOUND); + private static final Error ERROR_MALFORMED_FLAG = new Error(ErrorKind.MALFORMED_FLAG); + private static final Error ERROR_USER_NOT_SPECIFIED = new Error(ErrorKind.USER_NOT_SPECIFIED); + private static final Error ERROR_WRONG_TYPE = new Error(ErrorKind.WRONG_TYPE); + private static final Error ERROR_EXCEPTION = new Error(ErrorKind.EXCEPTION); + private final Kind kind; /** @@ -153,7 +161,15 @@ public static Fallthrough fallthrough() { * @return a reason object */ public static Error error(ErrorKind errorKind) { - return new Error(errorKind); + switch (errorKind) { + case CLIENT_NOT_READY: return ERROR_CLIENT_NOT_READY; + case EXCEPTION: return ERROR_EXCEPTION; + case FLAG_NOT_FOUND: return ERROR_FLAG_NOT_FOUND; + case MALFORMED_FLAG: return ERROR_MALFORMED_FLAG; + case USER_NOT_SPECIFIED: return ERROR_USER_NOT_SPECIFIED; + case WRONG_TYPE: return ERROR_WRONG_TYPE; + default: return new Error(errorKind); + } } /** diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 62a9786a5..9fde3f072 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.gson.annotations.JsonAdapter; import com.launchdarkly.client.value.LDValue; import org.slf4j.Logger; @@ -11,7 +12,8 @@ import static com.google.common.base.Preconditions.checkNotNull; import static com.launchdarkly.client.VersionedDataKind.FEATURES; -class FeatureFlag implements VersionedData { +@JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) +class FeatureFlag implements VersionedData, JsonHelpers.PostProcessingDeserializable { private final static Logger logger = LoggerFactory.getLogger(FeatureFlag.class); private String key; @@ -93,7 +95,9 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore featureStor for (int i = 0; i < rules.size(); i++) { Rule rule = rules.get(i); if (rule.matchesUser(featureStore, user)) { - return getValueForVariationOrRollout(rule, user, EvaluationReason.ruleMatch(i, rule.getId())); + EvaluationReason.RuleMatch precomputedReason = rule.getRuleMatchReason(); + EvaluationReason.RuleMatch reason = precomputedReason != null ? precomputedReason : EvaluationReason.ruleMatch(i, rule.getId()); + return getValueForVariationOrRollout(rule, user, reason); } } } @@ -125,7 +129,8 @@ private EvaluationReason checkPrerequisites(LDUser user, FeatureStore featureSto events.add(eventFactory.newPrerequisiteFeatureRequestEvent(prereqFeatureFlag, user, prereqEvalResult, this)); } if (!prereqOk) { - return EvaluationReason.prerequisiteFailed(prereq.getKey()); + EvaluationReason.PrerequisiteFailed precomputedReason = prereq.getPrerequisiteFailedReason(); + return precomputedReason != null ? precomputedReason : EvaluationReason.prerequisiteFailed(prereq.getKey()); } } return null; @@ -216,7 +221,22 @@ Integer getOffVariation() { boolean isClientSide() { return clientSide; } - + + // Precompute some invariant values for improved efficiency during evaluations - called from JsonHelpers.PostProcessingDeserializableTypeAdapter + public void afterDeserialized() { + if (prerequisites != null) { + for (Prerequisite p: prerequisites) { + p.setPrerequisiteFailedReason(EvaluationReason.prerequisiteFailed(p.getKey())); + } + } + if (rules != null) { + for (int i = 0; i < rules.size(); i++) { + Rule r = rules.get(i); + r.setRuleMatchReason(EvaluationReason.ruleMatch(i, r.getId())); + } + } + } + static class EvalResult { private final EvaluationDetail details; private final List prerequisiteEvents; diff --git a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java index e97245985..52c1ba4c8 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlagBuilder.java @@ -122,7 +122,9 @@ FeatureFlagBuilder deleted(boolean deleted) { } FeatureFlag build() { - return new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, + FeatureFlag flag = new FeatureFlag(key, version, on, prerequisites, salt, targets, rules, fallthrough, offVariation, variations, clientSide, trackEvents, trackEventsFallthrough, debugEventsUntilDate, deleted); + flag.afterDeserialized(); + return flag; } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/client/JsonHelpers.java new file mode 100644 index 000000000..6e43cd6d5 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/JsonHelpers.java @@ -0,0 +1,50 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; + +abstract class JsonHelpers { + /** + * Implement this interface on any internal class that needs to do some kind of post-processing after + * being unmarshaled from JSON. You must also add the annotation {@code JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory)} + * to the class for this to work. + */ + static interface PostProcessingDeserializable { + void afterDeserialized(); + } + + static class PostProcessingDeserializableTypeAdapterFactory implements TypeAdapterFactory { + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + return new PostProcessingDeserializableTypeAdapter<>(gson.getDelegateAdapter(this, type)); + } + } + + private static class PostProcessingDeserializableTypeAdapter extends TypeAdapter { + private final TypeAdapter baseAdapter; + + PostProcessingDeserializableTypeAdapter(TypeAdapter baseAdapter) { + this.baseAdapter = baseAdapter; + } + + @Override + public void write(JsonWriter out, T value) throws IOException { + baseAdapter.write(out, value); + } + + @Override + public T read(JsonReader in) throws IOException { + T instance = baseAdapter.read(in); + if (instance instanceof PostProcessingDeserializable) { + ((PostProcessingDeserializable)instance).afterDeserialized(); + } + return instance; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/Prerequisite.java b/src/main/java/com/launchdarkly/client/Prerequisite.java index 8df50ea56..7901444e5 100644 --- a/src/main/java/com/launchdarkly/client/Prerequisite.java +++ b/src/main/java/com/launchdarkly/client/Prerequisite.java @@ -4,6 +4,8 @@ class Prerequisite { private String key; private int variation; + private transient EvaluationReason.PrerequisiteFailed prerequisiteFailedReason; + // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation Prerequisite() {} @@ -19,4 +21,13 @@ String getKey() { int getVariation() { return variation; } + + // This value is precomputed when we deserialize a FeatureFlag from JSON + EvaluationReason.PrerequisiteFailed getPrerequisiteFailedReason() { + return prerequisiteFailedReason; + } + + void setPrerequisiteFailedReason(EvaluationReason.PrerequisiteFailed prerequisiteFailedReason) { + this.prerequisiteFailedReason = prerequisiteFailedReason; + } } diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java index 799340791..3ba0da86d 100644 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ b/src/main/java/com/launchdarkly/client/Rule.java @@ -11,6 +11,8 @@ class Rule extends VariationOrRollout { private String id; private List clauses; private boolean trackEvents; + + private transient EvaluationReason.RuleMatch ruleMatchReason; // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation Rule() { @@ -40,6 +42,15 @@ boolean isTrackEvents() { return trackEvents; } + // This value is precomputed when we deserialize a FeatureFlag from JSON + EvaluationReason.RuleMatch getRuleMatchReason() { + return ruleMatchReason; + } + + void setRuleMatchReason(EvaluationReason.RuleMatch ruleMatchReason) { + this.ruleMatchReason = ruleMatchReason; + } + boolean matchesUser(FeatureStore store, LDUser user) { for (Clause clause : clauses) { if (!clause.matchesUser(store, user)) { @@ -48,4 +59,4 @@ boolean matchesUser(FeatureStore store, LDUser user) { } return true; } -} \ No newline at end of file +} diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java index 41133c46b..0723165b0 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -6,7 +6,9 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +@SuppressWarnings("javadoc") public class EvaluationReasonTest { private static final Gson gson = new Gson(); @@ -58,6 +60,16 @@ public void testErrorSerialization() { assertEquals("ERROR(EXCEPTION)", reason.toString()); } + @Test + public void errorInstancesAreReused() { + for (EvaluationReason.ErrorKind errorKind: EvaluationReason.ErrorKind.values()) { + EvaluationReason.Error r0 = EvaluationReason.error(errorKind); + assertEquals(errorKind, r0.getErrorKind()); + EvaluationReason.Error r1 = EvaluationReason.error(errorKind); + assertSame(r0, r1); + } + } + private void assertJsonEqual(String expectedString, String actualString) { JsonElement expected = gson.fromJson(expectedString, JsonElement.class); JsonElement actual = gson.fromJson(actualString, JsonElement.class); diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 17328ed83..f5df08687 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -17,6 +17,7 @@ import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") public class FeatureFlagTest { @@ -234,6 +235,23 @@ public void flagReturnsOffVariationAndEventIfPrerequisiteIsNotMet() throws Excep assertEquals(f0.getKey(), event.prereqOf); } + @Test + public void prerequisiteFailedReasonInstanceIsReusedForSamePrerequisite() throws Exception { + FeatureFlag f0 = new FeatureFlagBuilder("feature0") + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("feature1", 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(1) + .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) + .build(); + FeatureFlag.EvalResult result0 = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + FeatureFlag.EvalResult result1 = f0.evaluate(BASE_USER, featureStore, EventFactory.DEFAULT); + + EvaluationReason expectedReason = EvaluationReason.prerequisiteFailed("feature1"); + assertEquals(expectedReason, result0.getDetails().getReason()); + assertSame(result0.getDetails().getReason(), result1.getDetails().getReason()); + } + @Test public void flagReturnsFallthroughVariationAndEventIfPrerequisiteIsMetAndThereAreNoRules() throws Exception { FeatureFlag f0 = new FeatureFlagBuilder("feature0") @@ -335,6 +353,26 @@ public void flagMatchesUserFromRules() { assertEquals(fromValue(LDValue.of("on"), 2, EvaluationReason.ruleMatch(1, "ruleid1")), result.getDetails()); assertEquals(0, result.getPrerequisiteEvents().size()); } + + @Test + public void ruleMatchReasonInstanceIsReusedForSameRule() { + Clause clause0 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("wrongkey")), false); + Clause clause1 = new Clause("key", Operator.in, Arrays.asList(LDValue.of("userkey")), false); + Rule rule0 = new Rule("ruleid0", Arrays.asList(clause0), 2, null); + Rule rule1 = new Rule("ruleid1", Arrays.asList(clause1), 2, null); + FeatureFlag f = featureFlagWithRules("feature", rule0, rule1); + LDUser user = new LDUser.Builder("userkey").build(); + LDUser otherUser = new LDUser.Builder("wrongkey").build(); + + FeatureFlag.EvalResult sameResult0 = f.evaluate(user, featureStore, EventFactory.DEFAULT); + FeatureFlag.EvalResult sameResult1 = f.evaluate(user, featureStore, EventFactory.DEFAULT); + FeatureFlag.EvalResult otherResult = f.evaluate(otherUser, featureStore, EventFactory.DEFAULT); + + assertEquals(EvaluationReason.ruleMatch(1, "ruleid1"), sameResult0.getDetails().getReason()); + assertSame(sameResult0.getDetails().getReason(), sameResult1.getDetails().getReason()); + + assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), otherResult.getDetails().getReason()); + } @Test public void ruleWithTooHighVariationReturnsMalformedFlagError() { diff --git a/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java b/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java new file mode 100644 index 000000000..90b2d9c50 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/FlagModelDeserializationTest.java @@ -0,0 +1,33 @@ +package com.launchdarkly.client; + +import com.google.gson.Gson; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@SuppressWarnings("javadoc") +public class FlagModelDeserializationTest { + private static final Gson gson = new Gson(); + + @Test + public void precomputedReasonsAreAddedToPrerequisites() { + String flagJson = "{\"key\":\"flagkey\",\"prerequisites\":[{\"key\":\"prereq0\"},{\"key\":\"prereq1\"}]}"; + FeatureFlag flag = gson.fromJson(flagJson, FeatureFlag.class); + assertNotNull(flag.getPrerequisites()); + assertEquals(2, flag.getPrerequisites().size()); + assertEquals(EvaluationReason.prerequisiteFailed("prereq0"), flag.getPrerequisites().get(0).getPrerequisiteFailedReason()); + assertEquals(EvaluationReason.prerequisiteFailed("prereq1"), flag.getPrerequisites().get(1).getPrerequisiteFailedReason()); + } + + @Test + public void precomputedReasonsAreAddedToRules() { + String flagJson = "{\"key\":\"flagkey\",\"rules\":[{\"id\":\"ruleid0\"},{\"id\":\"ruleid1\"}]}"; + FeatureFlag flag = gson.fromJson(flagJson, FeatureFlag.class); + assertNotNull(flag.getRules()); + assertEquals(2, flag.getRules().size()); + assertEquals(EvaluationReason.ruleMatch(0, "ruleid0"), flag.getRules().get(0).getRuleMatchReason()); + assertEquals(EvaluationReason.ruleMatch(1, "ruleid1"), flag.getRules().get(1).getRuleMatchReason()); + } +} From 57cd9c8a8298662125c1fc2f0ae55c74de9edd37 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Fri, 13 Dec 2019 18:40:04 +0000 Subject: [PATCH 213/327] [ch57885] Events in last batch (#158) --- .../client/DefaultEventProcessor.java | 11 +++-- .../client/DiagnosticAccumulator.java | 11 ++++- .../launchdarkly/client/DiagnosticEvent.java | 8 ++-- .../client/DefaultEventProcessorTest.java | 40 +++++++++++++++++++ .../client/DiagnosticAccumulatorTest.java | 34 +++++++++++++--- .../client/DiagnosticEventTest.java | 2 +- .../client/StreamProcessorTest.java | 8 ++-- 7 files changed, 92 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 637aa251c..7dec9ccc9 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -333,8 +333,8 @@ private void runMainLoop(BlockingQueue inbox, private void sendAndResetDiagnostics(EventBuffer outbox) { long droppedEvents = outbox.getAndClearDroppedCount(); - long eventsInQueue = outbox.getEventsInQueueCount(); - DiagnosticEvent diagnosticEvent = diagnosticAccumulator.createEventAndReset(droppedEvents, deduplicatedUsers, eventsInQueue); + // We pass droppedEvents and deduplicatedUsers as parameters here because they are updated frequently in the main loop so we want to avoid synchronization on them. + DiagnosticEvent diagnosticEvent = diagnosticAccumulator.createEventAndReset(droppedEvents, deduplicatedUsers); deduplicatedUsers = 0; diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticEvent)); } @@ -449,6 +449,9 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa return; } FlushPayload payload = outbox.getPayload(); + if (diagnosticAccumulator != null) { + diagnosticAccumulator.recordEventsInBatch(payload.events.length); + } busyFlushWorkersCount.incrementAndGet(); if (payloadQueue.offer(payload)) { // These events now belong to the next available flush worker, so drop them from our state @@ -566,10 +569,6 @@ long getAndClearDroppedCount() { return res; } - long getEventsInQueueCount() { - return events.size(); - } - FlushPayload getPayload() { Event[] eventsOut = events.toArray(new Event[events.size()]); EventSummarizer.EventSummary summary = summarizer.snapshot(); diff --git a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java index 9f96e16d5..22782294f 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticAccumulator.java @@ -2,11 +2,13 @@ import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; class DiagnosticAccumulator { final DiagnosticId diagnosticId; volatile long dataSinceDate; + private final AtomicInteger eventsInLastBatch = new AtomicInteger(0); private final Object streamInitsLock = new Object(); private ArrayList streamInits = new ArrayList<>(); @@ -21,15 +23,20 @@ void recordStreamInit(long timestamp, long durationMillis, boolean failed) { } } - DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long deduplicatedUsers, long eventsInQueue) { + void recordEventsInBatch(int eventsInBatch) { + eventsInLastBatch.set(eventsInBatch); + } + + DiagnosticEvent.Statistics createEventAndReset(long droppedEvents, long deduplicatedUsers) { long currentTime = System.currentTimeMillis(); List eventInits; synchronized (streamInitsLock) { eventInits = streamInits; streamInits = new ArrayList<>(); } + long eventsInBatch = eventsInLastBatch.getAndSet(0); DiagnosticEvent.Statistics res = new DiagnosticEvent.Statistics(currentTime, diagnosticId, dataSinceDate, droppedEvents, - deduplicatedUsers, eventsInQueue, eventInits); + deduplicatedUsers, eventsInBatch, eventInits); dataSinceDate = currentTime; return res; } diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 2204ef7bb..7f517f213 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -19,7 +19,7 @@ static class StreamInit { long durationMillis; boolean failed; - public StreamInit(long timestamp, long durationMillis, boolean failed) { + StreamInit(long timestamp, long durationMillis, boolean failed) { this.timestamp = timestamp; this.durationMillis = durationMillis; this.failed = failed; @@ -31,16 +31,16 @@ static class Statistics extends DiagnosticEvent { final long dataSinceDate; final long droppedEvents; final long deduplicatedUsers; - final long eventsInQueue; + final long eventsInLastBatch; final List streamInits; Statistics(long creationDate, DiagnosticId id, long dataSinceDate, long droppedEvents, long deduplicatedUsers, - long eventsInQueue, List streamInits) { + long eventsInLastBatch, List streamInits) { super("diagnostic", creationDate, id); this.dataSinceDate = dataSinceDate; this.droppedEvents = droppedEvents; this.deduplicatedUsers = deduplicatedUsers; - this.eventsInQueue = eventsInQueue; + this.eventsInLastBatch = eventsInLastBatch; this.streamInits = streamInits; } } diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 2ec5c644e..3e94f73cf 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -538,6 +538,46 @@ public void periodicDiagnosticEventHasStatisticsBody() throws Exception { assertThat(statsEvent.id, samePropertyValuesAs(diagnosticId)); assertThat(statsEvent.dataSinceDate, equalTo(dataSinceDate)); assertThat(statsEvent.creationDate, equalTo(diagnosticAccumulator.dataSinceDate)); + assertThat(statsEvent.deduplicatedUsers, equalTo(0L)); + assertThat(statsEvent.eventsInLastBatch, equalTo(0L)); + assertThat(statsEvent.droppedEvents, equalTo(0L)); + } + } + } + + @Test + public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() throws Exception { + FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).trackEvents(true).build(); + FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); + LDValue value = LDValue.of("value"); + Event.FeatureRequest fe1 = EventFactory.DEFAULT.newFeatureRequestEvent(flag1, user, + simpleEvaluation(1, value), LDValue.ofNull()); + Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, + simpleEvaluation(1, value), LDValue.ofNull()); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse(), eventsSuccessResponse())) { + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseDiagConfig(server).build(), diagnosticAccumulator)) { + // Ignore the initial diagnostic event + server.takeRequest(); + + ep.sendEvent(fe1); + ep.sendEvent(fe2); + ep.flush(); + // Ignore normal events + server.takeRequest(); + + ep.postDiagnostic(); + RecordedRequest periodicReq = server.takeRequest(); + + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.getBody().readUtf8(), DiagnosticEvent.Statistics.class); + + assertNotNull(statsEvent); + assertThat(statsEvent.deduplicatedUsers, equalTo(1L)); + assertThat(statsEvent.eventsInLastBatch, equalTo(3L)); + assertThat(statsEvent.droppedEvents, equalTo(0L)); } } } diff --git a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java b/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java index 83468a1af..1b19a24b7 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticAccumulatorTest.java @@ -2,9 +2,9 @@ import org.junit.Test; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; public class DiagnosticAccumulatorTest { @@ -13,20 +13,44 @@ public void createsDiagnosticStatisticsEvent() { DiagnosticId diagnosticId = new DiagnosticId("SDK_KEY"); DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); long startDate = diagnosticAccumulator.dataSinceDate; - DiagnosticEvent.Statistics diagnosticStatisticsEvent = diagnosticAccumulator.createEventAndReset(10, 15, 20); + DiagnosticEvent.Statistics diagnosticStatisticsEvent = diagnosticAccumulator.createEventAndReset(10, 15); assertSame(diagnosticId, diagnosticStatisticsEvent.id); assertEquals(10, diagnosticStatisticsEvent.droppedEvents); assertEquals(15, diagnosticStatisticsEvent.deduplicatedUsers); - assertEquals(20, diagnosticStatisticsEvent.eventsInQueue); + assertEquals(0, diagnosticStatisticsEvent.eventsInLastBatch); assertEquals(startDate, diagnosticStatisticsEvent.dataSinceDate); } @Test - public void resetsDataSinceDate() throws InterruptedException { + public void canRecordStreamInit() { + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId("SDK_KEY")); + diagnosticAccumulator.recordStreamInit(1000, 200, false); + DiagnosticEvent.Statistics statsEvent = diagnosticAccumulator.createEventAndReset(0, 0); + assertEquals(1, statsEvent.streamInits.size()); + assertEquals(1000, statsEvent.streamInits.get(0).timestamp); + assertEquals(200, statsEvent.streamInits.get(0).durationMillis); + assertEquals(false, statsEvent.streamInits.get(0).failed); + } + + @Test + public void canRecordEventsInBatch() { + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId("SDK_KEY")); + diagnosticAccumulator.recordEventsInBatch(100); + DiagnosticEvent.Statistics statsEvent = diagnosticAccumulator.createEventAndReset(0, 0); + assertEquals(100, statsEvent.eventsInLastBatch); + } + + @Test + public void resetsAccumulatorFieldsOnCreate() throws InterruptedException { DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId("SDK_KEY")); + diagnosticAccumulator.recordStreamInit(1000, 200, false); + diagnosticAccumulator.recordEventsInBatch(100); long startDate = diagnosticAccumulator.dataSinceDate; Thread.sleep(2); - diagnosticAccumulator.createEventAndReset(0, 0, 0); + diagnosticAccumulator.createEventAndReset(0, 0); assertNotEquals(startDate, diagnosticAccumulator.dataSinceDate); + DiagnosticEvent.Statistics resetEvent = diagnosticAccumulator.createEventAndReset(0,0); + assertEquals(0, resetEvent.streamInits.size()); + assertEquals(0, resetEvent.eventsInLastBatch); } } diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index 4f6155ab2..94470ca65 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -35,7 +35,7 @@ public void testSerialization() { assertEquals(1000, jsonObject.getAsJsonPrimitive("dataSinceDate").getAsLong()); assertEquals(1, jsonObject.getAsJsonPrimitive("droppedEvents").getAsLong()); assertEquals(2, jsonObject.getAsJsonPrimitive("deduplicatedUsers").getAsLong()); - assertEquals(3, jsonObject.getAsJsonPrimitive("eventsInQueue").getAsLong()); + assertEquals(3, jsonObject.getAsJsonPrimitive("eventsInLastBatch").getAsLong()); JsonArray initsJson = jsonObject.getAsJsonArray("streamInits"); assertEquals(1, initsJson.size()); JsonObject initJson = initsJson.get(0).getAsJsonObject(); diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index a39f0339e..75ac1b116 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -317,7 +317,7 @@ public void streamInitDiagnosticRecordedOnOpen() throws Exception { createStreamProcessor(SDK_KEY, config, acc).start(); eventHandler.onMessage("put", emptyPutEvent()); long timeAfterOpen = System.currentTimeMillis(); - DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0, 0); + DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(1, event.streamInits.size()); DiagnosticEvent.StreamInit init = event.streamInits.get(0); assertFalse(init.failed); @@ -334,7 +334,7 @@ public void streamInitDiagnosticRecordedOnErrorDuringInit() throws Exception { createStreamProcessor(SDK_KEY, config, acc).start(); errorHandler.onConnectionError(new IOException()); long timeAfterOpen = System.currentTimeMillis(); - DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0, 0); + DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(1, event.streamInits.size()); DiagnosticEvent.StreamInit init = event.streamInits.get(0); assertTrue(init.failed); @@ -350,9 +350,9 @@ public void streamInitDiagnosticNotRecordedOnErrorAfterInit() throws Exception { createStreamProcessor(SDK_KEY, config, acc).start(); eventHandler.onMessage("put", emptyPutEvent()); // Drop first stream init from stream open - acc.createEventAndReset(0, 0, 0); + acc.createEventAndReset(0, 0); errorHandler.onConnectionError(new IOException()); - DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0, 0); + DiagnosticEvent.Statistics event = acc.createEventAndReset(0, 0); assertEquals(0, event.streamInits.size()); } From 480d88654ec5169dbc613c7830c52757174c11eb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 13 Dec 2019 12:30:53 -0800 Subject: [PATCH 214/327] don't let user fall outside of last bucket in rollout --- .../java/com/launchdarkly/client/VariationOrRollout.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java index c9213e915..ba7b53941 100644 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ b/src/main/java/com/launchdarkly/client/VariationOrRollout.java @@ -30,7 +30,7 @@ class VariationOrRollout { Integer variationIndexForUser(LDUser user, String key, String salt) { if (variation != null) { return variation; - } else if (rollout != null) { + } else if (rollout != null && rollout.variations != null && !rollout.variations.isEmpty()) { String bucketBy = rollout.bucketBy == null ? "key" : rollout.bucketBy; float bucket = bucketUser(user, key, bucketBy, salt); float sum = 0F; @@ -40,6 +40,12 @@ Integer variationIndexForUser(LDUser user, String key, String salt) { return wv.variation; } } + // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due + // to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag + // data could contain buckets that don't actually add up to 100000. Rather than returning an error in + // this case (or changing the scaling, which would potentially change the results for *all* users), we + // will simply put the user in the last bucket. + return rollout.variations.get(rollout.variations.size() - 1).variation; } return null; } From 7e732d6c31c4d89d1ae9331ab1bb71a64b51df7e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 30 Dec 2019 11:36:53 -0800 Subject: [PATCH 215/327] add unit tests for basic bucketing logic --- .../client/VariationOrRolloutTest.java | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java b/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java index 67d9d07f4..e56543f35 100644 --- a/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java +++ b/src/test/java/com/launchdarkly/client/VariationOrRolloutTest.java @@ -1,10 +1,58 @@ package com.launchdarkly.client; -import static org.junit.Assert.assertEquals; +import com.launchdarkly.client.VariationOrRollout.WeightedVariation; +import org.hamcrest.Matchers; import org.junit.Test; +import java.util.Arrays; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") public class VariationOrRolloutTest { + @Test + public void variationIndexIsReturnedForBucket() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey = "flagkey"; + String salt = "salt"; + + // First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, + // so we can construct a rollout whose second bucket just barely contains that value + int bucketValue = (int)(VariationOrRollout.bucketUser(user, flagKey, "key", salt) * 100000); + assertThat(bucketValue, greaterThanOrEqualTo(1)); + assertThat(bucketValue, Matchers.lessThan(100000)); + + int badVariationA = 0, matchedVariation = 1, badVariationB = 2; + List variations = Arrays.asList( + new WeightedVariation(badVariationA, bucketValue), // end of bucket range is not inclusive, so it will *not* match the target value + new WeightedVariation(matchedVariation, 1), // size of this bucket is 1, so it only matches that specific value + new WeightedVariation(badVariationB, 100000 - (bucketValue + 1))); + VariationOrRollout vr = new VariationOrRollout(null, new VariationOrRollout.Rollout(variations, null)); + + Integer resultVariation = vr.variationIndexForUser(user, flagKey, salt); + assertEquals(Integer.valueOf(matchedVariation), resultVariation); + } + + @Test + public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey = "flagkey"; + String salt = "salt"; + + // We'll construct a list of variations that stops right at the target bucket value + int bucketValue = (int)(VariationOrRollout.bucketUser(user, flagKey, "key", salt) * 100000); + + List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); + VariationOrRollout vr = new VariationOrRollout(null, new VariationOrRollout.Rollout(variations, null)); + + Integer resultVariation = vr.variationIndexForUser(user, flagKey, salt); + assertEquals(Integer.valueOf(0), resultVariation); + } + @Test public void canBucketByIntAttributeSameAsString() { LDUser user = new LDUser.Builder("key") From 103aea30d0c6c4fe5c5de8b70ad5a9da0d46a4a2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 3 Jan 2020 18:34:56 -0800 Subject: [PATCH 216/327] start renaming FeatureStore & UpdateProcessor to DataStore & DataSource --- .../com/launchdarkly/client/Components.java | 62 ++++++++++++++- .../client/InMemoryFeatureStore.java | 2 +- .../com/launchdarkly/client/LDClient.java | 8 +- .../com/launchdarkly/client/LDConfig.java | 76 +++++++++++++++---- .../client/files/FileComponents.java | 2 +- .../client/files/FileDataSourceFactory.java | 2 +- .../client/LDClientEvaluationTest.java | 12 +-- .../client/LDClientEventTest.java | 4 +- .../client/LDClientLddModeTest.java | 2 +- .../client/LDClientOfflineTest.java | 4 +- .../com/launchdarkly/client/LDClientTest.java | 16 ++-- .../client/StreamProcessorTest.java | 2 +- .../files/ClientWithFileDataSourceTest.java | 2 +- 13 files changed, 147 insertions(+), 47 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 65c993869..e95785915 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -17,9 +17,25 @@ public abstract class Components { private static final UpdateProcessorFactory nullUpdateProcessorFactory = new NullUpdateProcessorFactory(); /** - * Returns a factory for the default in-memory implementation of {@link FeatureStore}. + * Returns a factory for the default in-memory implementation of a data store. + * + * Note that the interface is still named {@link FeatureStoreFactory}, but in a future version it + * will be renamed to {@code DataStoreFactory}. + * * @return a factory object + * @since 4.11.0 + * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ + public static FeatureStoreFactory inMemoryDataStore() { + return inMemoryFeatureStoreFactory; + } + + /** + * Deprecated name for {@link #inMemoryDataStore()}. + * @return a factory object + * @deprecated Use {@link #inMemoryDataStore()}. + */ + @Deprecated public static FeatureStoreFactory inMemoryFeatureStore() { return inMemoryFeatureStoreFactory; } @@ -28,6 +44,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 + * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ public static RedisFeatureStoreBuilder redisFeatureStore() { return new RedisFeatureStoreBuilder(); @@ -38,6 +55,7 @@ public static RedisFeatureStoreBuilder redisFeatureStore() { * specifying the Redis URI. * @param redisUri the URI of the Redis host * @return a factory/builder object + * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { return new RedisFeatureStoreBuilder(redisUri); @@ -48,6 +66,7 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * 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 + * @see LDConfig.Builder#eventProcessorFactory(EventProcessorFactory) */ public static EventProcessorFactory defaultEventProcessor() { return defaultEventProcessorFactory; @@ -57,17 +76,34 @@ 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 + * @see LDConfig.Builder#eventProcessorFactory(EventProcessorFactory) */ public static EventProcessorFactory nullEventProcessor() { return nullEventProcessorFactory; } /** - * 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). + * Returns a factory for the default implementation of the component for receiving feature flag data + * from LaunchDarkly. Based on your configuration, this implementation uses either streaming or + * polling, or does nothing if the client is offline, or in LDD mode. + * + * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it + * will be renamed to {@code DataSourceFactory}. + * + * @return a factory object + * @since 4.11.0 + * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) + */ + public static UpdateProcessorFactory defaultDataSource() { + return defaultUpdateProcessorFactory; + } + + /** + * Deprecated name for {@link #defaultDataSource()}. * @return a factory object + * @deprecated Use {@link #defaultDataSource()}. */ + @Deprecated public static UpdateProcessorFactory defaultUpdateProcessor() { return defaultUpdateProcessorFactory; } @@ -75,8 +111,24 @@ 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. + * + * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it + * will be renamed to {@code DataSourceFactory}. + * + * @return a factory object + * @since 4.11.0 + * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) + */ + public static UpdateProcessorFactory nullDataSource() { + return nullUpdateProcessorFactory; + } + + /** + * Deprecated name for {@link #nullDataSource()}. * @return a factory object + * @deprecated Use {@link #nullDataSource()}. */ + @Deprecated public static UpdateProcessorFactory nullUpdateProcessor() { return nullUpdateProcessorFactory; } @@ -109,6 +161,7 @@ private static final class DefaultUpdateProcessorFactory implements UpdateProces // Note, logger uses LDClient class name for backward compatibility private static final Logger logger = LoggerFactory.getLogger(LDClient.class); + @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { if (config.offline) { @@ -132,6 +185,7 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea } private static final class NullUpdateProcessorFactory implements UpdateProcessorFactory { + @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { return new UpdateProcessor.NullUpdateProcessor(); diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index b8db96e3a..05ad4bbb2 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -9,7 +9,7 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * A thread-safe, versioned store for {@link FeatureFlag} objects and related data based on a + * A thread-safe, versioned store for feature flags and related data based on a * {@link HashMap}. This is the default implementation of {@link FeatureStore}. */ public class InMemoryFeatureStore implements FeatureStore { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 24cf3cd73..5bf3e4c02 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -72,8 +72,8 @@ public LDClient(String sdkKey, LDConfig config) { // of instances that we created ourselves from a factory. this.shouldCloseFeatureStore = false; } else { - FeatureStoreFactory factory = config.featureStoreFactory == null ? - Components.inMemoryFeatureStore() : config.featureStoreFactory; + FeatureStoreFactory factory = config.dataStoreFactory == null ? + Components.inMemoryDataStore() : config.dataStoreFactory; store = factory.createFeatureStore(); this.shouldCloseFeatureStore = true; } @@ -83,8 +83,8 @@ public LDClient(String sdkKey, LDConfig config) { Components.defaultEventProcessor() : config.eventProcessorFactory; this.eventProcessor = epFactory.createEventProcessor(sdkKey, config); - UpdateProcessorFactory upFactory = config.updateProcessorFactory == null ? - Components.defaultUpdateProcessor() : config.updateProcessorFactory; + UpdateProcessorFactory upFactory = config.dataSourceFactory == null ? + Components.defaultDataSource() : config.dataSourceFactory; this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, config, featureStore); Future startFuture = updateProcessor.start(); if (config.startWaitMillis > 0L) { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 38ff7b475..00db0bbe0 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -56,9 +56,9 @@ public final class LDConfig { final Authenticator proxyAuthenticator; final boolean stream; final FeatureStore deprecatedFeatureStore; - final FeatureStoreFactory featureStoreFactory; + final FeatureStoreFactory dataStoreFactory; final EventProcessorFactory eventProcessorFactory; - final UpdateProcessorFactory updateProcessorFactory; + final UpdateProcessorFactory dataSourceFactory; final boolean useLdd; final boolean offline; final boolean allAttributesPrivate; @@ -88,9 +88,9 @@ protected LDConfig(Builder builder) { this.streamURI = builder.streamURI; this.stream = builder.stream; this.deprecatedFeatureStore = builder.featureStore; - this.featureStoreFactory = builder.featureStoreFactory; + this.dataStoreFactory = builder.dataStoreFactory; this.eventProcessorFactory = builder.eventProcessorFactory; - this.updateProcessorFactory = builder.updateProcessorFactory; + this.dataSourceFactory = builder.dataSourceFactory; this.useLdd = builder.useLdd; this.offline = builder.offline; this.allAttributesPrivate = builder.allAttributesPrivate; @@ -155,9 +155,9 @@ public static class Builder { private boolean sendEvents = true; private long pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; private FeatureStore featureStore = null; - private FeatureStoreFactory featureStoreFactory = Components.inMemoryFeatureStore(); + private FeatureStoreFactory dataStoreFactory = Components.inMemoryDataStore(); private EventProcessorFactory eventProcessorFactory = Components.defaultEventProcessor(); - private UpdateProcessorFactory updateProcessorFactory = Components.defaultUpdateProcessor(); + private UpdateProcessorFactory dataSourceFactory = Components.defaultDataSource(); private long startWaitMillis = DEFAULT_START_WAIT_MILLIS; private int samplingInterval = DEFAULT_SAMPLING_INTERVAL; private long reconnectTimeMillis = DEFAULT_RECONNECT_TIME_MILLIS; @@ -207,6 +207,24 @@ public Builder streamURI(URI streamURI) { return this; } + /** + * Sets the implementation of the data store to be used for holding feature flags and + * related data received from LaunchDarkly, using a factory object. The default is + * {@link Components#inMemoryDataStore()}, but you may use {@link Components#redisFeatureStore()} + * or a custom implementation. + * + * Note that the interface is still called {@link FeatureStoreFactory}, but in a future version + * it will be renamed to {@code DataStoreFactory}. + * + * @param factory the factory object + * @return the builder + * @since 4.11.0 + */ + public Builder dataStore(FeatureStoreFactory factory) { + this.dataStoreFactory = factory; + return this; + } + /** * Sets the implementation of {@link FeatureStore} to be used for holding feature flags and * related data received from LaunchDarkly. The default is {@link InMemoryFeatureStore}, but @@ -221,26 +239,37 @@ public Builder featureStore(FeatureStore store) { } /** - * Sets the implementation of {@link FeatureStore} to be used for holding feature flags and - * related data received from LaunchDarkly, using a factory object. The default is - * {@link Components#inMemoryFeatureStore()}, but you may use {@link Components#redisFeatureStore()} - * or a custom implementation. + * Deprecated name for {@link #dataStore(FeatureStoreFactory)}. * @param factory the factory object * @return the builder * @since 4.0.0 + * @deprecated Use {@link #dataStore(FeatureStoreFactory)}. */ + @Deprecated public Builder featureStoreFactory(FeatureStoreFactory factory) { - this.featureStoreFactory = factory; + this.dataStoreFactory = factory; return this; } - + /** * Sets the implementation of {@link EventProcessor} to be used for processing analytics events, * using a factory object. The default is {@link Components#defaultEventProcessor()}, but * you may choose to use a custom implementation (for instance, a test fixture). * @param factory the factory object * @return the builder + * @since 4.11.0 + */ + public Builder eventProcessor(EventProcessorFactory factory) { + this.eventProcessorFactory = factory; + return this; + } + + /** + * Deprecated name for {@link #eventProcessor(EventProcessorFactory)}. + * @param factory the factory object + * @return the builder * @since 4.0.0 + * @deprecated Use {@link #eventProcessor(EventProcessorFactory)}. */ public Builder eventProcessorFactory(EventProcessorFactory factory) { this.eventProcessorFactory = factory; @@ -248,15 +277,32 @@ public Builder eventProcessorFactory(EventProcessorFactory factory) { } /** - * Sets the implementation of {@link UpdateProcessor} to be used for receiving feature flag data, - * using a factory object. The default is {@link Components#defaultUpdateProcessor()}, but + * Sets the implementation of the component that receives feature flag data from LaunchDarkly, + * using a factory object. The default is {@link Components#defaultDataSource()}, but * you may choose to use a custom implementation (for instance, a test fixture). + * + * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version + * it will be renamed to {@code DataSourceFactory}. + * + * @param factory the factory object + * @return the builder + * @since 4.11.0 + */ + public Builder dataSource(UpdateProcessorFactory factory) { + this.dataSourceFactory = factory; + return this; + } + + /** + * Deprecated name for {@link #dataSource(UpdateProcessorFactory)}. * @param factory the factory object * @return the builder * @since 4.0.0 + * @deprecated Use {@link #dataSource(UpdateProcessorFactory)}. */ + @Deprecated public Builder updateProcessorFactory(UpdateProcessorFactory factory) { - this.updateProcessorFactory = factory; + this.dataSourceFactory = factory; return this; } diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java index 390fb75a3..f893b7f47 100644 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -16,7 +16,7 @@ * .filePaths("./testData/flags.json") * .autoUpdate(true); * LDConfig config = new LDConfig.Builder() - * .updateProcessorFactory(f) + * .dataSource(f) * .build(); * *

    diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index 216c56213..8f0d36cf0 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -14,7 +14,7 @@ /** * 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)}. + * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}. *

    * For more details, see {@link FileComponents}. * diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 501e7c11f..ff41ad1c1 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -34,9 +34,9 @@ public class LDClientEvaluationTest { private FeatureStore featureStore = TestUtil.initedFeatureStore(); private LDConfig config = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(featureStore)) + .dataStore(specificFeatureStore(featureStore)) .eventProcessorFactory(Components.nullEventProcessor()) - .updateProcessorFactory(Components.nullUpdateProcessor()) + .dataSource(Components.nullUpdateProcessor()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); @@ -222,9 +222,9 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { public void appropriateErrorIfClientNotInitialized() throws Exception { FeatureStore badFeatureStore = new InMemoryFeatureStore(); LDConfig badConfig = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(badFeatureStore)) + .dataStore(specificFeatureStore(badFeatureStore)) .eventProcessorFactory(Components.nullEventProcessor()) - .updateProcessorFactory(specificUpdateProcessor(failedUpdateProcessor())) + .dataSource(specificUpdateProcessor(failedUpdateProcessor())) .startWaitMillis(0) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { @@ -263,9 +263,9 @@ public void appropriateErrorIfValueWrongType() throws Exception { public void appropriateErrorForUnexpectedException() throws Exception { FeatureStore badFeatureStore = featureStoreThatThrowsException(new RuntimeException("sorry")); LDConfig badConfig = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(badFeatureStore)) + .dataStore(specificFeatureStore(badFeatureStore)) .eventProcessorFactory(Components.nullEventProcessor()) - .updateProcessorFactory(Components.nullUpdateProcessor()) + .dataSource(Components.nullUpdateProcessor()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index f71a56bf3..941c2615d 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -29,9 +29,9 @@ public class LDClientEventTest { private FeatureStore featureStore = TestUtil.initedFeatureStore(); private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(featureStore)) + .dataStore(specificFeatureStore(featureStore)) .eventProcessorFactory(specificEventProcessor(eventSink)) - .updateProcessorFactory(Components.nullUpdateProcessor()) + .dataSource(Components.nullUpdateProcessor()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java index 76ee3611a..afc4ca6c5 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -50,7 +50,7 @@ public void lddModeClientGetsFlagFromFeatureStore() throws IOException { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .useLdd(true) - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificFeatureStore(testFeatureStore)) .build(); FeatureFlag flag = flagWithValue("key", LDValue.of(true)); testFeatureStore.upsert(FEATURES, flag); diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index cff7ed994..45aa48bfa 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -66,7 +66,7 @@ public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificFeatureStore(testFeatureStore)) .build(); testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -80,7 +80,7 @@ public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificFeatureStore(testFeatureStore)) .build(); testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index b585536c9..02dc39657 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -201,7 +201,7 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); + .dataStore(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(true).times(1); replayAll(); @@ -218,7 +218,7 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); + .dataStore(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(true).times(1); replayAll(); @@ -234,7 +234,7 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex FeatureStore testFeatureStore = new InMemoryFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); + .dataStore(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(false).times(1); replayAll(); @@ -251,7 +251,7 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .featureStoreFactory(specificFeatureStore(testFeatureStore)); + .dataStore(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(false).times(1); replayAll(); @@ -267,7 +267,7 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() - .featureStoreFactory(specificFeatureStore(testFeatureStore)) + .dataStore(specificFeatureStore(testFeatureStore)) .startWaitMillis(0L); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(false); @@ -294,8 +294,8 @@ public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { replay(store); LDConfig.Builder config = new LDConfig.Builder() - .updateProcessorFactory(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) - .featureStoreFactory(specificFeatureStore(store)) + .dataSource(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) + .dataStore(specificFeatureStore(store)) .sendEvents(false); client = new LDClient("SDK_KEY", config.build()); @@ -340,7 +340,7 @@ private void expectEventsSent(int count) { } private LDClientInterface createMockClient(LDConfig.Builder config) { - config.updateProcessorFactory(TestUtil.specificUpdateProcessor(updateProcessor)); + config.dataSource(TestUtil.specificUpdateProcessor(updateProcessor)); config.eventProcessorFactory(TestUtil.specificEventProcessor(eventProcessor)); return new LDClient("SDK_KEY", config.build()); } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 92a45136b..ff49176c3 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -65,7 +65,7 @@ public class StreamProcessorTest extends EasyMockSupport { @Before public void setup() { featureStore = new InMemoryFeatureStore(); - configBuilder = new LDConfig.Builder().featureStoreFactory(specificFeatureStore(featureStore)); + configBuilder = new LDConfig.Builder().dataStore(specificFeatureStore(featureStore)); mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createStrictMock(EventSource.class); } diff --git a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java index e8ec26040..cc8e344bd 100644 --- a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java @@ -22,7 +22,7 @@ private LDClient makeClient() throws Exception { FileDataSourceFactory fdsf = FileComponents.fileDataSource() .filePaths(resourceFilePath("all-properties.json")); LDConfig config = new LDConfig.Builder() - .updateProcessorFactory(fdsf) + .dataSource(fdsf) .sendEvents(false) .build(); return new LDClient("sdkKey", config); From 93f9b4ef0200068db006cfc140cd9562875057bb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 3 Jan 2020 20:30:09 -0800 Subject: [PATCH 217/327] move Redis and FileData stuff into new integrations subpackage --- .../com/launchdarkly/client/Components.java | 13 +- .../client/RedisFeatureStore.java | 231 ++---------------- .../client/RedisFeatureStoreBuilder.java | 81 +++--- .../client/files/DataBuilder.java | 32 --- .../launchdarkly/client/files/DataLoader.java | 58 ----- .../client/files/DataLoaderException.java | 43 ---- .../client/files/FileComponents.java | 95 +------ .../client/files/FileDataSourceFactory.java | 28 +-- .../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/package-info.java | 4 +- .../client/integrations/FileData.java | 116 +++++++++ .../integrations/FileDataSourceBuilder.java | 83 +++++++ .../FileDataSourceImpl.java} | 104 +++++++- .../integrations/FileDataSourceParsing.java | 223 +++++++++++++++++ .../client/integrations/Redis.java | 23 ++ .../integrations/RedisDataStoreBuilder.java | 187 ++++++++++++++ .../integrations/RedisDataStoreImpl.java | 196 +++++++++++++++ .../client/integrations/package-info.java | 12 + ...a => DeprecatedRedisFeatureStoreTest.java} | 19 +- .../client/RedisFeatureStoreBuilderTest.java | 106 -------- .../client/files/JsonFlagFileParserTest.java | 7 - .../client/files/YamlFlagFileParserTest.java | 7 - .../ClientWithFileDataSourceTest.java | 17 +- .../DataLoaderTest.java | 16 +- .../FileDataSourceTest.java | 24 +- .../FileDataSourceTestData.java} | 8 +- .../integrations/FlagFileParserJsonTest.java | 10 + .../FlagFileParserTestBase.java | 17 +- .../integrations/FlagFileParserYamlTest.java | 10 + .../RedisFeatureStoreBuilderTest.java | 53 ++++ .../integrations/RedisFeatureStoreTest.java | 62 +++++ 35 files changed, 1199 insertions(+), 886 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/files/DataBuilder.java delete mode 100644 src/main/java/com/launchdarkly/client/files/DataLoader.java delete mode 100644 src/main/java/com/launchdarkly/client/files/DataLoaderException.java delete mode 100644 src/main/java/com/launchdarkly/client/files/FlagFactory.java delete mode 100644 src/main/java/com/launchdarkly/client/files/FlagFileParser.java delete mode 100644 src/main/java/com/launchdarkly/client/files/FlagFileRep.java delete mode 100644 src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java delete mode 100644 src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/FileData.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java rename src/main/java/com/launchdarkly/client/{files/FileDataSource.java => integrations/FileDataSourceImpl.java} (56%) create mode 100644 src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/Redis.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/package-info.java rename src/test/java/com/launchdarkly/client/{RedisFeatureStoreTest.java => DeprecatedRedisFeatureStoreTest.java} (66%) delete mode 100644 src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java delete mode 100644 src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java delete mode 100644 src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java rename src/test/java/com/launchdarkly/client/{files => integrations}/ClientWithFileDataSourceTest.java (65%) rename src/test/java/com/launchdarkly/client/{files => integrations}/DataLoaderTest.java (86%) rename src/test/java/com/launchdarkly/client/{files => integrations}/FileDataSourceTest.java (87%) rename src/test/java/com/launchdarkly/client/{files/TestData.java => integrations/FileDataSourceTestData.java} (90%) create mode 100644 src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java rename src/test/java/com/launchdarkly/client/{files => integrations}/FlagFileParserTestBase.java (76%) create mode 100644 src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java create mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java create mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index e95785915..fb48bc1dd 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -41,22 +41,23 @@ public static FeatureStoreFactory inMemoryFeatureStore() { } /** - * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, - * using {@link RedisFeatureStoreBuilder#DEFAULT_URI}. + * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. * @return a factory/builder object - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()}. */ + @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore() { return new RedisFeatureStoreBuilder(); } /** - * Returns a factory with builder methods for creating a Redis-backed implementation of {@link FeatureStore}, - * specifying the Redis URI. + * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. * @param redisUri the URI of the Redis host * @return a factory/builder object - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} and + * {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder#uri(URI)}. */ + @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { return new RedisFeatureStoreBuilder(redisUri); } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 55091fa40..ada6651d1 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -1,73 +1,57 @@ package com.launchdarkly.client; -import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.CacheStats; import com.launchdarkly.client.utils.CachingStoreWrapper; -import com.launchdarkly.client.utils.FeatureStoreCore; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; -import java.util.HashMap; -import java.util.List; import java.util.Map; -import 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; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Transaction; -import redis.clients.util.JedisURIHelper; - /** - * An implementation of {@link FeatureStore} backed by Redis. Also - * supports an optional in-memory cache configuration that can be used to improve performance. + * Deprecated implementation class for the Redis-based persistent data store. + *

    + * Instead of referencing this class directly, use {@link com.launchdarkly.client.integrations.Redis#dataStore()} to obtain a builder object. + * + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} */ public class RedisFeatureStore implements FeatureStore { - private static final Logger logger = LoggerFactory.getLogger(RedisFeatureStore.class); - - // 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; + // The actual implementation is now in the com.launchdarkly.integrations package. This class remains + // visible for backward compatibility, but simply delegates to an instance of the underlying store. + + private final FeatureStore wrappedStore; @Override public void init(Map, Map> allData) { - wrapper.init(allData); + wrappedStore.init(allData); } @Override public T get(VersionedDataKind kind, String key) { - return wrapper.get(kind, key); + return wrappedStore.get(kind, key); } @Override public Map all(VersionedDataKind kind) { - return wrapper.all(kind); + return wrappedStore.all(kind); } @Override public void upsert(VersionedDataKind kind, T item) { - wrapper.upsert(kind, item); + wrappedStore.upsert(kind, item); } @Override public void delete(VersionedDataKind kind, String key, int version) { - wrapper.delete(kind, key, version); + wrappedStore.delete(kind, key, version); } @Override public boolean initialized() { - return wrapper.initialized(); + return wrappedStore.initialized(); } @Override public void close() throws IOException { - wrapper.close(); + wrappedStore.close(); } /** @@ -76,7 +60,7 @@ public void close() throws IOException { * @return the cache statistics object. */ public CacheStats getCacheStats() { - return wrapper.getCacheStats(); + return ((CachingStoreWrapper)wrappedStore).getCacheStats(); } /** @@ -87,192 +71,15 @@ public CacheStats getCacheStats() { * @param builder the configured builder to construct the store with. */ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, - // the overloads that accept a URI do not accept the other parameters we need to set, so we need - // to decompose the URI. - String host = builder.uri.getHost(); - int port = builder.uri.getPort(); - String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; - int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); - boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); - - String extra = tls ? " with TLS" : ""; - if (password != null) { - extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; - } - logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); - - JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); - JedisPool pool = new JedisPool(poolConfig, - host, - port, - builder.connectTimeout, - builder.socketTimeout, - password, - database, - null, // clientName - tls, - null, // sslSocketFactory - null, // sslParameters - null // hostnameVerifier - ); - - String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? - RedisFeatureStoreBuilder.DEFAULT_PREFIX : - builder.prefix; - - this.core = new Core(pool, prefix); - this.wrapper = CachingStoreWrapper.builder(this.core).caching(builder.caching) - .build(); + wrappedStore = builder.wrappedBuilder.createFeatureStore(); } /** * Creates a new store instance that connects to Redis with a default connection (localhost port 6379) and no in-memory cache. * @deprecated Please use {@link Components#redisFeatureStore()} instead. */ + @Deprecated public RedisFeatureStore() { this(new RedisFeatureStoreBuilder().caching(FeatureStoreCacheConfig.disabled())); } - - 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; - } - - @Override - 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 item; - } - } - - @Override - public Map getAllInternal(VersionedDataKind kind) { - try (Jedis jedis = pool.getResource()) { - Map allJson = jedis.hgetAll(itemsKey(kind)); - Map result = new HashMap<>(); - - for (Map.Entry entry : allJson.entrySet()) { - VersionedData item = unmarshalJson(kind, entry.getValue()); - result.put(entry.getKey(), item); - } - return result; - } - } - - @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(), marshalJson(item)); - } - } - - t.set(initedKey(), ""); - t.exec(); - } - } - - @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData 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 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 initializedInternal() { - try (Jedis jedis = pool.getResource()) { - return jedis.exists(initedKey()); - } - } - - @Override - public void close() throws IOException { - logger.info("Closing LaunchDarkly RedisFeatureStore"); - pool.destroy(); - } - - @VisibleForTesting - void setUpdateListener(UpdateListener updateListener) { - this.updateListener = updateListener; - } - - private String itemsKey(VersionedDataKind kind) { - return prefix + ":" + kind.getNamespace(); - } - - private String initedKey() { - return prefix + ":$inited"; - } - - private T getRedis(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; - } - - return unmarshalJson(kind, json); - } - } - - static interface UpdateListener { - void aboutToUpdate(String baseKey, String itemKey); - } - - @VisibleForTesting - void setUpdateListener(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 7c5661e82..d84e3aa58 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -1,25 +1,23 @@ package com.launchdarkly.client; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; +import com.launchdarkly.client.integrations.Redis; +import com.launchdarkly.client.integrations.RedisDataStoreBuilder; import java.net.URI; import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; +import redis.clients.jedis.JedisPoolConfig; + /** - * A builder for configuring the Redis-based persistent feature store. - * - * Obtain an instance of this class by calling {@link Components#redisFeatureStore()} or {@link Components#redisFeatureStore(URI)}. - * Builder calls can be chained, for example: - * - *
    
    - * FeatureStore store = Components.redisFeatureStore()
    - *      .database(1)
    - *      .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
    - *      .build();
    - * 
    + * Deprecated builder class for the Redis-based persistent data store. + *

    + * The replacement for this class is {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder}. + * This class is retained for backward compatibility and will be removed in a future version. + * + * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} */ +@Deprecated public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { /** * The default value for the Redis URI: {@code redis://localhost:6379} @@ -40,25 +38,21 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { */ 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; - Integer database = null; - String password = null; - boolean tls = false; + final RedisDataStoreBuilder wrappedBuilder; + + // We have to keep track of these caching parameters separately in order to support some deprecated setters FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCacheConfig, but are used by deprecated setters + boolean refreshStaleValues = false; boolean asyncRefresh = false; - JedisPoolConfig poolConfig = null; - // These constructors are called only from Implementations + // These constructors are called only from Components RedisFeatureStoreBuilder() { - this.uri = DEFAULT_URI; + wrappedBuilder = Redis.dataStore(); } RedisFeatureStoreBuilder(URI uri) { - this.uri = uri; + this(); + wrappedBuilder.uri(uri); } /** @@ -69,8 +63,10 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. */ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { - this.uri = uri; - this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); + this(); + wrappedBuilder.uri(uri); + caching = caching.ttlSeconds(cacheTimeSecs); + wrappedBuilder.caching(caching); } /** @@ -84,8 +80,10 @@ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. */ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cacheTimeSecs) throws URISyntaxException { - this.uri = new URI(scheme, null, host, port, null, null, null); - this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); + this(); + wrappedBuilder.uri(new URI(scheme, null, host, port, null, null, null)); + caching = caching.ttlSeconds(cacheTimeSecs); + wrappedBuilder.caching(caching); } /** @@ -100,7 +98,7 @@ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cache * @since 4.7.0 */ public RedisFeatureStoreBuilder database(Integer database) { - this.database = database; + wrappedBuilder.database(database); return this; } @@ -116,7 +114,7 @@ public RedisFeatureStoreBuilder database(Integer database) { * @since 4.7.0 */ public RedisFeatureStoreBuilder password(String password) { - this.password = password; + wrappedBuilder.password(password); return this; } @@ -133,7 +131,7 @@ public RedisFeatureStoreBuilder password(String password) { * @since 4.7.0 */ public RedisFeatureStoreBuilder tls(boolean tls) { - this.tls = tls; + wrappedBuilder.tls(tls); return this; } @@ -149,6 +147,7 @@ public RedisFeatureStoreBuilder tls(boolean tls) { */ public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { this.caching = caching; + wrappedBuilder.caching(caching); return this; } @@ -187,13 +186,14 @@ public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { 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 ? + if (refreshStaleValues) { + caching = caching.staleValuesPolicy(this.asyncRefresh ? FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH); } else { - this.caching = this.caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); + caching = caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); } + wrappedBuilder.caching(caching); } /** @@ -203,7 +203,7 @@ private void updateCachingStaleValuesPolicy() { * @return the builder */ public RedisFeatureStoreBuilder prefix(String prefix) { - this.prefix = prefix; + wrappedBuilder.prefix(prefix); return this; } @@ -218,8 +218,9 @@ public RedisFeatureStoreBuilder prefix(String prefix) { * @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) + caching = caching.ttl(cacheTime, timeUnit) .staleValuesPolicy(this.caching.getStaleValuesPolicy()); + wrappedBuilder.caching(caching); return this; } @@ -230,7 +231,7 @@ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { * @return the builder */ public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { - this.poolConfig = poolConfig; + wrappedBuilder.poolConfig(poolConfig); return this; } @@ -243,7 +244,7 @@ public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { * @return the builder */ public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { - this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); + wrappedBuilder.connectTimeout(connectTimeout, timeUnit); return this; } @@ -256,7 +257,7 @@ public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit time * @return the builder */ public RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { - this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); + wrappedBuilder.socketTimeout(socketTimeout, timeUnit); return this; } diff --git a/src/main/java/com/launchdarkly/client/files/DataBuilder.java b/src/main/java/com/launchdarkly/client/files/DataBuilder.java deleted file mode 100644 index e9bc580a9..000000000 --- a/src/main/java/com/launchdarkly/client/files/DataBuilder.java +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index 0b4ad431c..000000000 --- a/src/main/java/com/launchdarkly/client/files/DataLoader.java +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 184a3211a..000000000 --- a/src/main/java/com/launchdarkly/client/files/DataLoaderException.java +++ /dev/null @@ -1,43 +0,0 @@ -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 index f893b7f47..63a575555 100644 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -1,100 +1,11 @@ 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()
    - *         .dataSource(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 {@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: - *

    - *     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): - *

    - * {
    - *     "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. - * + * Deprecated entry point for the file data source. * @since 4.5.0 + * @deprecated Use {@link com.launchdarkly.client.integrations.FileData}. */ +@Deprecated public abstract class FileComponents { /** * Creates a {@link FileDataSourceFactory} which you can use to configure the file data diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index 8f0d36cf0..ded4a2dd5 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -4,25 +4,21 @@ import com.launchdarkly.client.LDConfig; import com.launchdarkly.client.UpdateProcessor; import com.launchdarkly.client.UpdateProcessorFactory; +import com.launchdarkly.client.integrations.FileDataSourceBuilder; +import com.launchdarkly.client.integrations.FileData; 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#dataSource(UpdateProcessorFactory)}. - *

    - * For more details, see {@link FileComponents}. + * Deprecated name for {@link FileDataSourceBuilder}. Use {@link FileData#dataSource()} to obtain the + * new builder. * * @since 4.5.0 + * @deprecated */ public class FileDataSourceFactory implements UpdateProcessorFactory { - private final List sources = new ArrayList<>(); - private boolean autoUpdate = false; + private final FileDataSourceBuilder wrappedBuilder = new FileDataSourceBuilder(); /** * Adds any number of source files for loading flag data, specifying each file path as a string. The files will @@ -36,9 +32,7 @@ public class FileDataSourceFactory implements UpdateProcessorFactory { * @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)); - } + wrappedBuilder.filePaths(filePaths); return this; } @@ -52,9 +46,7 @@ public FileDataSourceFactory filePaths(String... filePaths) throws InvalidPathEx * @return the same factory object */ public FileDataSourceFactory filePaths(Path... filePaths) { - for (Path p: filePaths) { - sources.add(p); - } + wrappedBuilder.filePaths(filePaths); return this; } @@ -69,7 +61,7 @@ public FileDataSourceFactory filePaths(Path... filePaths) { * @return the same factory object */ public FileDataSourceFactory autoUpdate(boolean autoUpdate) { - this.autoUpdate = autoUpdate; + wrappedBuilder.autoUpdate(autoUpdate); return this; } @@ -78,6 +70,6 @@ public FileDataSourceFactory autoUpdate(boolean autoUpdate) { */ @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return new FileDataSource(featureStore, new DataLoader(sources), autoUpdate); + return wrappedBuilder.createUpdateProcessor(sdkKey, config, featureStore); } } \ 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 deleted file mode 100644 index 19af56282..000000000 --- a/src/main/java/com/launchdarkly/client/files/FlagFactory.java +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index ed0de72a0..000000000 --- a/src/main/java/com/launchdarkly/client/files/FlagFileParser.java +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index db04fb51b..000000000 --- a/src/main/java/com/launchdarkly/client/files/FlagFileRep.java +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index c895fd6ab..000000000 --- a/src/main/java/com/launchdarkly/client/files/JsonFlagFileParser.java +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index f4e352dfc..000000000 --- a/src/main/java/com/launchdarkly/client/files/YamlFlagFileParser.java +++ /dev/null @@ -1,52 +0,0 @@ -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/main/java/com/launchdarkly/client/files/package-info.java b/src/main/java/com/launchdarkly/client/files/package-info.java index a5a3eafa4..da8abb785 100644 --- a/src/main/java/com/launchdarkly/client/files/package-info.java +++ b/src/main/java/com/launchdarkly/client/files/package-info.java @@ -1,6 +1,4 @@ /** - * Package for the file data source component, which may be useful in tests. - *

    - * The entry point is {@link com.launchdarkly.client.files.FileComponents}. + * Deprecated package replaced by {@link com.launchdarkly.client.integrations.FileData}. */ package com.launchdarkly.client.files; diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/client/integrations/FileData.java new file mode 100644 index 000000000..e2466c485 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/FileData.java @@ -0,0 +1,116 @@ +package com.launchdarkly.client.integrations; + +/** + * Integration between the LaunchDarkly SDK and file data. + * + * The file data source 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. See {@link #dataSource()} for details. + * + * @since 4.11.0 + */ +public abstract class FileData { + /** + * Creates a {@link FileDataSourceBuilder} which you can use to configure the file data source. + * This allows you to use local files as a source of feature flag state, instead of using an actual + * LaunchDarkly connection. + * + * This object can be modified with {@link FileDataSourceBuilder} methods for any desired + * custom settings, before including it in the SDK configuration with + * {@link com.launchdarkly.client.LDConfig.Builder#dataSource(com.launchdarkly.client.UpdateProcessorFactory)}. + * + * At a minimum, you will want to call {@link FileDataSourceBuilder#filePaths(String...)} to specify + * your data file(s); you can also use {@link FileDataSourceBuilder#autoUpdate(boolean)} to + * specify that flags should be reloaded when a file is modified. See {@link FileDataSourceBuilder} + * for all configuration options. + *

    +   *     FileDataSourceFactory f = FileData.dataSource()
    +   *         .filePaths("./testData/flags.json")
    +   *         .autoUpdate(true);
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .dataSource(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 {@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: + *

    +   *     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): + *

    +   * {
    +   *     "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. + * + * @return a data source configuration object + * @since 4.11.0 + */ + public static FileDataSourceBuilder dataSource() { + return new FileDataSourceBuilder(); + } + + private FileData() {} +} diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java new file mode 100644 index 000000000..4c3cd1993 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java @@ -0,0 +1,83 @@ +package com.launchdarkly.client.integrations; + +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 FileData#dataSource()}, + * call the builder method {@link #filePaths(String...)} to specify file path(s), + * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}. + *

    + * For more details, see {@link FileData}. + * + * @since 4.11.0 + */ +public final class FileDataSourceBuilder 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 FileDataSourceBuilder 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 FileDataSourceBuilder 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. + *

    + * 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 + */ + public FileDataSourceBuilder 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 FileDataSourceImpl(featureStore, sources, autoUpdate); + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSource.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java similarity index 56% rename from src/main/java/com/launchdarkly/client/files/FileDataSource.java rename to src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java index e040e7902..cd2244564 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSource.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java @@ -1,21 +1,34 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.common.util.concurrent.Futures; +import com.google.gson.JsonElement; import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.UpdateProcessor; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFactory; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.FileSystems; +import java.nio.file.Files; 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.ArrayList; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -25,20 +38,20 @@ 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 + * Implements taking flag data from files and putting it into the data store, at startup time and * optionally whenever files change. */ -class FileDataSource implements UpdateProcessor { - private static final Logger logger = LoggerFactory.getLogger(FileDataSource.class); +final class FileDataSourceImpl implements UpdateProcessor { + private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.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) { + FileDataSourceImpl(FeatureStore store, List sources, boolean autoUpdate) { this.store = store; - this.dataLoader = dataLoader; + this.dataLoader = new DataLoader(sources); FileWatcher fw = null; if (autoUpdate) { @@ -65,7 +78,7 @@ public Future start() { if (fileWatcher != null) { fileWatcher.start(new Runnable() { public void run() { - FileDataSource.this.reload(); + FileDataSourceImpl.this.reload(); } }); } @@ -77,7 +90,7 @@ private boolean reload() { DataBuilder builder = new DataBuilder(); try { dataLoader.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { logger.error(e.getDescription()); return false; } @@ -101,7 +114,7 @@ public void close() throws IOException { /** * If auto-updating is enabled, this component watches for file changes on a worker thread. */ - private static class FileWatcher implements Runnable { + private static final class FileWatcher implements Runnable { private final WatchService watchService; private final Set watchedFilePaths; private Runnable fileModifiedAction; @@ -165,7 +178,7 @@ public void run() { public void start(Runnable fileModifiedAction) { this.fileModifiedAction = fileModifiedAction; - thread = new Thread(this, FileDataSource.class.getName()); + thread = new Thread(this, FileDataSourceImpl.class.getName()); thread.setDaemon(true); thread.start(); } @@ -177,4 +190,75 @@ public void stop() { } } } + + /** + * 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. + */ + static 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 FileDataException + { + 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 (FileDataException e) { + throw new FileDataException(e.getMessage(), e.getCause(), p); + } catch (IOException e) { + throw new FileDataException(null, e, p); + } + } + } + } + + /** + * 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. + */ + static final class DataBuilder { + private final Map, Map> allData = new HashMap<>(); + + public Map, Map> build() { + return allData; + } + + public void add(VersionedDataKind kind, VersionedData item) throws FileDataException { + @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 FileDataException("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/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java new file mode 100644 index 000000000..08083e4d9 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java @@ -0,0 +1,223 @@ +package com.launchdarkly.client.integrations; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; + +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.error.YAMLException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.file.Path; +import java.util.Map; + +abstract class FileDataSourceParsing { + /** + * 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") + static final class FileDataException extends Exception { + private final Path filePath; + + public FileDataException(String message, Throwable cause, Path filePath) { + super(message, cause); + this.filePath = filePath; + } + + public FileDataException(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(); + } + } + + /** + * 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}. + */ + static 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; + } + } + + static abstract class FlagFileParser { + private static final FlagFileParser jsonParser = new JsonFlagFileParser(); + private static final FlagFileParser yamlParser = new YamlFlagFileParser(); + + public abstract FlagFileRep parse(InputStream input) throws FileDataException, 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; + } + } + } + } + + static final class JsonFlagFileParser extends FlagFileParser { + private static final Gson gson = new Gson(); + + @Override + public FlagFileRep parse(InputStream input) throws FileDataException, IOException { + try { + return parseJson(gson.fromJson(new InputStreamReader(input), JsonElement.class)); + } catch (JsonSyntaxException e) { + throw new FileDataException("cannot parse JSON", e); + } + } + + public FlagFileRep parseJson(JsonElement tree) throws FileDataException, IOException { + try { + return gson.fromJson(tree, FlagFileRep.class); + } catch (JsonSyntaxException e) { + throw new FileDataException("cannot parse JSON", e); + } + } + } + + /** + * 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) + *
    + */ + static 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 FileDataException, IOException { + Object root; + try { + root = yaml.load(input); + } catch (YAMLException e) { + throw new FileDataException("unable to parse YAML", e); + } + JsonElement jsonRoot = gson.toJsonTree(root); + return jsonFileParser.parseJson(jsonRoot); + } + } + + /** + * 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. + */ + static final class FlagFactory { + private static final Gson gson = new Gson(); + + static VersionedData flagFromJson(String jsonString) { + return flagFromJson(gson.fromJson(jsonString, JsonElement.class)); + } + + 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. + */ + 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); + } + + static VersionedData segmentFromJson(String jsonString) { + return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); + } + + static VersionedData segmentFromJson(JsonElement jsonTree) { + return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java new file mode 100644 index 000000000..4b4351b9d --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/Redis.java @@ -0,0 +1,23 @@ +package com.launchdarkly.client.integrations; + +/** + * Integration between the LaunchDarkly SDK and Redis. + * + * @since 4.11.0 + */ +public abstract class Redis { + /** + * Returns a builder object for creating a Redis-backed data store. + * + * This object can be modified with {@link RedisDataStoreBuilder} methods for any desired + * custom settings, before including it in the SDK configuration with + * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. + * + * @return a data store configuration object + */ + public static RedisDataStoreBuilder dataStore() { + return new RedisDataStoreBuilder(); + } + + private Redis() {} +} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java new file mode 100644 index 000000000..bbc50491d --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -0,0 +1,187 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FeatureStoreCacheConfig; +import com.launchdarkly.client.FeatureStoreFactory; +import com.launchdarkly.client.utils.CachingStoreWrapper; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +import static com.google.common.base.Preconditions.checkNotNull; + +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Protocol; + +/** + * A builder for configuring the Redis-based persistent data store. + *

    + * Obtain an instance of this class by calling {@link Redis#dataStore()}. After calling its methods + * to specify any desired custom settings, you can pass it directly into the SDK configuration with + * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. + * You do not need to call {@link #createFeatureStore()} yourself to build the actual data store; that + * will be done by the SDK. + *

    + * Builder calls can be chained, for example: + * + *

    
    + * LDConfig config = new LDConfig.Builder()
    + *      .dataStore(
    + *           Redis.dataStore()
    + *               .database(1)
    + *               .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
    + *      )
    + *      .build();
    + * 
    + * + * @since 4.11.0 + */ +public final class RedisDataStoreBuilder implements FeatureStoreFactory { + /** + * The default value for the Redis URI: {@code redis://localhost:6379} + */ + public static final URI DEFAULT_URI = URI.create("redis://localhost:6379"); + + /** + * The default value for {@link #prefix(String)}. + */ + public static final String DEFAULT_PREFIX = "launchdarkly"; + + URI uri = DEFAULT_URI; + String prefix = DEFAULT_PREFIX; + int connectTimeout = Protocol.DEFAULT_TIMEOUT; + int socketTimeout = Protocol.DEFAULT_TIMEOUT; + Integer database = null; + String password = null; + boolean tls = false; + FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + JedisPoolConfig poolConfig = null; + + // These constructors are called only from Implementations + RedisDataStoreBuilder() { + } + + /** + * Specifies the database number to use. + *

    + * The database number can also be specified in the Redis URI, in the form {@code redis://host:port/NUMBER}. Any + * non-null value that you set with {@link #database(Integer)} will override the URI. + * + * @param database the database number, or null to fall back to the URI or the default + * @return the builder + */ + public RedisDataStoreBuilder database(Integer database) { + this.database = database; + return this; + } + + /** + * Specifies a password that will be sent to Redis in an AUTH command. + *

    + * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port}. Any + * password that you set with {@link #password(String)} will override the URI. + * + * @param password the password + * @return the builder + */ + public RedisDataStoreBuilder password(String password) { + this.password = password; + return this; + } + + /** + * Optionally enables TLS for secure connections to Redis. + *

    + * This is equivalent to specifying a Redis URI that begins with {@code rediss:} rather than {@code redis:}. + *

    + * Note that not all Redis server distributions support TLS. + * + * @param tls true to enable TLS + * @return the builder + */ + public RedisDataStoreBuilder tls(boolean tls) { + this.tls = tls; + return this; + } + + /** + * Specifies a Redis host URI other than {@link #DEFAULT_URI}. + * + * @param redisUri the URI of the Redis host + * @return the builder + */ + public RedisDataStoreBuilder uri(URI redisUri) { + this.uri = checkNotNull(uri); + return this; + } + + /** + * Specifies whether local caching should be enabled and if so, sets the cache properties. Local + * caching is enabled by default; see {@link FeatureStoreCacheConfig#DEFAULT}. To disable it, pass + * {@link FeatureStoreCacheConfig#disabled()} to this method. + * + * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters + * @return the builder + */ + public RedisDataStoreBuilder caching(FeatureStoreCacheConfig caching) { + this.caching = caching; + return this; + } + + /** + * Optionally configures the namespace prefix for all keys stored in Redis. + * + * @param prefix the namespace prefix + * @return the builder + */ + public RedisDataStoreBuilder prefix(String prefix) { + this.prefix = prefix; + return this; + } + + /** + * Optional override if you wish to specify your own configuration to the underlying Jedis pool. + * + * @param poolConfig the Jedis pool configuration. + * @return the builder + */ + public RedisDataStoreBuilder poolConfig(JedisPoolConfig poolConfig) { + this.poolConfig = poolConfig; + return this; + } + + /** + * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to + * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} + * + * @param connectTimeout the timeout + * @param timeUnit the time unit for the timeout + * @return the builder + */ + public RedisDataStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { + this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); + return this; + } + + /** + * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to + * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} + * + * @param socketTimeout the socket timeout + * @param timeUnit the time unit for the timeout + * @return the builder + */ + public RedisDataStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { + this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); + return this; + } + + /** + * Called internally by the SDK to create the actual data store instance. + * @return the data store configured by this builder + */ + public FeatureStore createFeatureStore() { + RedisDataStoreImpl core = new RedisDataStoreImpl(this); + return CachingStoreWrapper.builder(core).caching(this.caching).build(); + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java new file mode 100644 index 000000000..24e3968b7 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java @@ -0,0 +1,196 @@ +package com.launchdarkly.client.integrations; + +import com.google.common.annotations.VisibleForTesting; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.utils.FeatureStoreCore; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import 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; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Transaction; +import redis.clients.util.JedisURIHelper; + +class RedisDataStoreImpl implements FeatureStoreCore { + private static final Logger logger = LoggerFactory.getLogger(RedisDataStoreImpl.class); + + private final JedisPool pool; + private final String prefix; + private UpdateListener updateListener; + + RedisDataStoreImpl(RedisDataStoreBuilder builder) { + // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, + // the overloads that accept a URI do not accept the other parameters we need to set, so we need + // to decompose the URI. + String host = builder.uri.getHost(); + int port = builder.uri.getPort(); + String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; + int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); + boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); + + String extra = tls ? " with TLS" : ""; + if (password != null) { + extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; + } + logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); + + JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); + JedisPool pool = new JedisPool(poolConfig, + host, + port, + builder.connectTimeout, + builder.socketTimeout, + password, + database, + null, // clientName + tls, + null, // sslSocketFactory + null, // sslParameters + null // hostnameVerifier + ); + + String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? + RedisDataStoreBuilder.DEFAULT_PREFIX : + builder.prefix; + + this.pool = pool; + this.prefix = prefix; + } + + @Override + 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 item; + } + } + + @Override + public Map getAllInternal(VersionedDataKind kind) { + try (Jedis jedis = pool.getResource()) { + Map allJson = jedis.hgetAll(itemsKey(kind)); + Map result = new HashMap<>(); + + for (Map.Entry entry : allJson.entrySet()) { + VersionedData item = unmarshalJson(kind, entry.getValue()); + result.put(entry.getKey(), item); + } + return result; + } + } + + @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(), marshalJson(item)); + } + } + + t.set(initedKey(), ""); + t.exec(); + } + } + + @Override + public VersionedData upsertInternal(VersionedDataKind kind, VersionedData 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 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 initializedInternal() { + try (Jedis jedis = pool.getResource()) { + return jedis.exists(initedKey()); + } + } + + @Override + public void close() throws IOException { + logger.info("Closing LaunchDarkly RedisFeatureStore"); + pool.destroy(); + } + + @VisibleForTesting + void setUpdateListener(UpdateListener updateListener) { + this.updateListener = updateListener; + } + + private String itemsKey(VersionedDataKind kind) { + return prefix + ":" + kind.getNamespace(); + } + + private String initedKey() { + return prefix + ":$inited"; + } + + private T getRedis(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; + } + + return unmarshalJson(kind, json); + } + + static interface UpdateListener { + void aboutToUpdate(String baseKey, String itemKey); + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/package-info.java b/src/main/java/com/launchdarkly/client/integrations/package-info.java new file mode 100644 index 000000000..589a2c63a --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/package-info.java @@ -0,0 +1,12 @@ +/** + * This package contains integration tools for connecting the SDK to other software components. + *

    + * In the current main LaunchDarkly Java SDK library, this package contains {@link com.launchdarkly.client.integrations.Redis} + * (for using Redis as a store for flag data) and {@link com.launchdarkly.client.integrations.FileData} + * (for reading flags from a file in testing). Other SDK add-on libraries, such as database integrations, + * will define their classes in {@code com.launchdarkly.client.integrations} as well. + *

    + * The general pattern for factory methods in this package is {@code ToolName#componentType()}, + * such as {@code Redis#dataStore()} or {@code FileData#dataSource()}. + */ +package com.launchdarkly.client.integrations; diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java similarity index 66% rename from src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java rename to src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java index e58d56388..08febe5ad 100644 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java @@ -1,8 +1,5 @@ package com.launchdarkly.client; -import com.launchdarkly.client.RedisFeatureStore.UpdateListener; - -import org.junit.Assume; import org.junit.BeforeClass; import java.net.URI; @@ -11,11 +8,12 @@ import redis.clients.jedis.Jedis; -public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { +@SuppressWarnings({ "javadoc", "deprecation" }) +public class DeprecatedRedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - public RedisFeatureStoreTest(boolean cached) { + public DeprecatedRedisFeatureStoreTest(boolean cached) { super(cached); } @@ -43,15 +41,4 @@ protected void clearAllData() { client.flushDB(); } } - - @Override - protected boolean setUpdateHook(RedisFeatureStore storeUnderTest, final Runnable hook) { - storeUnderTest.setUpdateListener(new UpdateListener() { - @Override - public void aboutToUpdate(String baseKey, String itemKey) { - hook.run(); - } - }); - return true; - } } diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java deleted file mode 100644 index 64fb15068..000000000 --- a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package com.launchdarkly.client; - -import org.junit.Test; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -public class RedisFeatureStoreBuilderTest { - @Test - public void testDefaultValues() { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @Test - public void testConstructorSpecifyingUri() { - URI uri = URI.create("redis://host:1234"); - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(uri); - assertEquals(uri, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @SuppressWarnings("deprecation") - @Test - public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder("badscheme", "example", 1234, 100); - assertEquals(URI.create("badscheme://example:1234"), conf.uri); - assertEquals(100, conf.caching.getCacheTime()); - assertEquals(TimeUnit.SECONDS, conf.caching.getCacheTimeUnit()); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(FeatureStoreCacheConfig.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(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH, conf.caching.getStaleValuesPolicy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testAsyncRefresh() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true).asyncRefresh(true); - assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC, conf.caching.getStaleValuesPolicy()); - } - - @SuppressWarnings("deprecation") - @Test - public void testRefreshStaleValuesWithoutAsyncRefresh() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().asyncRefresh(true); - assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); - } - - @Test - public void testPrefixConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().prefix("prefix"); - assertEquals("prefix", conf.prefix); - } - - @Test - public void testConnectTimeoutConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().connectTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.connectTimeout); - } - - @Test - public void testSocketTimeoutConfigured() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().socketTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.socketTimeout); - } - - @SuppressWarnings("deprecation") - @Test - public void testCacheTimeWithUnit() throws URISyntaxException { - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().cacheTime(2000, TimeUnit.MILLISECONDS); - assertEquals(2000, conf.caching.getCacheTime()); - assertEquals(TimeUnit.MILLISECONDS, conf.caching.getCacheTimeUnit()); - } - - @Test - public void testPoolConfigConfigured() throws URISyntaxException { - JedisPoolConfig poolConfig = new JedisPoolConfig(); - RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().poolConfig(poolConfig); - assertEquals(poolConfig, conf.poolConfig); - } -} diff --git a/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java b/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java deleted file mode 100644 index 0110105f6..000000000 --- a/src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java +++ /dev/null @@ -1,7 +0,0 @@ -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/YamlFlagFileParserTest.java b/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java deleted file mode 100644 index 9b94e3801..000000000 --- a/src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.launchdarkly.client.files; - -public class YamlFlagFileParserTest extends FlagFileParserTestBase { - public YamlFlagFileParserTest() { - super(new YamlFlagFileParser(), ".yml"); - } -} diff --git a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java similarity index 65% rename from src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java rename to src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java index cc8e344bd..f7c365b41 100644 --- a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.LDClient; @@ -7,22 +7,23 @@ 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 com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_KEY; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_VALUE; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +@SuppressWarnings("javadoc") public class ClientWithFileDataSourceTest { private static final LDUser user = new LDUser.Builder("userkey").build(); private LDClient makeClient() throws Exception { - FileDataSourceFactory fdsf = FileComponents.fileDataSource() + FileDataSourceBuilder fdsb = FileData.dataSource() .filePaths(resourceFilePath("all-properties.json")); LDConfig config = new LDConfig.Builder() - .dataSource(fdsf) + .dataSource(fdsb) .sendEvents(false) .build(); return new LDClient("sdkKey", config); diff --git a/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java b/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java similarity index 86% rename from src/test/java/com/launchdarkly/client/files/DataLoaderTest.java rename to src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java index 9145c7d32..b78585783 100644 --- a/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.common.collect.ImmutableList; import com.google.gson.Gson; @@ -6,6 +6,9 @@ import com.google.gson.JsonObject; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.integrations.FileDataSourceImpl.DataBuilder; +import com.launchdarkly.client.integrations.FileDataSourceImpl.DataLoader; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; import org.junit.Assert; import org.junit.Test; @@ -14,12 +17,13 @@ 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 com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; +@SuppressWarnings("javadoc") public class DataLoaderTest { private static final Gson gson = new Gson(); private DataBuilder builder = new DataBuilder(); @@ -70,7 +74,7 @@ public void duplicateFlagKeyInFlagsThrowsException() throws Exception { DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), resourceFilePath("flag-with-duplicate-key.json"))); ds.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); } } @@ -81,7 +85,7 @@ public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Excepti DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), resourceFilePath("value-with-duplicate-key.json"))); ds.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); } } @@ -92,7 +96,7 @@ public void duplicateSegmentKeyThrowsException() throws Exception { DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("segment-only.json"), resourceFilePath("segment-with-duplicate-key.json"))); ds.load(builder); - } catch (DataLoaderException e) { + } catch (FileDataException e) { assertThat(e.getMessage(), containsString("key \"seg1\" was already defined")); } } diff --git a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java similarity index 87% rename from src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java rename to src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index 62924d9d5..716b68e8a 100644 --- a/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.InMemoryFeatureStore; @@ -14,28 +14,28 @@ 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 com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.getResourceContents; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.fail; +@SuppressWarnings("javadoc") 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; + private final FileDataSourceBuilder factory; public FileDataSourceTest() throws Exception { factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); } - private static FileDataSourceFactory makeFactoryWithFile(Path path) { - return fileDataSource().filePaths(path); + private static FileDataSourceBuilder makeFactoryWithFile(Path path) { + return com.launchdarkly.client.integrations.FileData.dataSource().filePaths(path); } @Test @@ -94,7 +94,7 @@ public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { @Test public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { File file = makeTempFlagFile(); - FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()); try { setFileContents(file, getResourceContents("flag-only.json")); try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { @@ -115,7 +115,7 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { @Test public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { File file = makeTempFlagFile(); - FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag @@ -142,7 +142,7 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { File file = makeTempFlagFile(); setFileContents(file, "not valid"); - FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { diff --git a/src/test/java/com/launchdarkly/client/files/TestData.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java similarity index 90% rename from src/test/java/com/launchdarkly/client/files/TestData.java rename to src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java index d1f098d7c..d222f4c77 100644 --- a/src/test/java/com/launchdarkly/client/files/TestData.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -14,7 +14,8 @@ import java.util.Map; import java.util.Set; -public class TestData { +@SuppressWarnings("javadoc") +public class FileDataSourceTestData { private static final Gson gson = new Gson(); // These should match the data in our test files @@ -40,12 +41,11 @@ public class TestData { 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); + URL resource = FileDataSourceTestData.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/integrations/FlagFileParserJsonTest.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java new file mode 100644 index 000000000..c23a66772 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java @@ -0,0 +1,10 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.integrations.FileDataSourceParsing.JsonFlagFileParser; + +@SuppressWarnings("javadoc") +public class FlagFileParserJsonTest extends FlagFileParserTestBase { + public FlagFileParserJsonTest() { + super(new JsonFlagFileParser(), ".json"); + } +} diff --git a/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java similarity index 76% rename from src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java rename to src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java index d6165e279..fd2be268f 100644 --- a/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java +++ b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java @@ -1,4 +1,8 @@ -package com.launchdarkly.client.files; +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; +import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; import org.junit.Test; @@ -6,14 +10,15 @@ 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 com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUES; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAGS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_SEGMENTS; +import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.nullValue; +@SuppressWarnings("javadoc") public abstract class FlagFileParserTestBase { private final FlagFileParser parser; private final String fileExtension; @@ -63,7 +68,7 @@ public void canParseFileWithOnlySegment() throws Exception { } } - @Test(expected = DataLoaderException.class) + @Test(expected = FileDataException.class) public void throwsExpectedErrorForBadFile() throws Exception { try (FileInputStream input = openFile("malformed")) { parser.parse(input); diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java new file mode 100644 index 000000000..3ad640e92 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java @@ -0,0 +1,10 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.integrations.FileDataSourceParsing.YamlFlagFileParser; + +@SuppressWarnings("javadoc") +public class FlagFileParserYamlTest extends FlagFileParserTestBase { + public FlagFileParserYamlTest() { + super(new YamlFlagFileParser(), ".yml"); + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java new file mode 100644 index 000000000..de8cf2570 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java @@ -0,0 +1,53 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.FeatureStoreCacheConfig; + +import org.junit.Test; + +import java.net.URISyntaxException; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Protocol; + +@SuppressWarnings("javadoc") +public class RedisFeatureStoreBuilderTest { + @Test + public void testDefaultValues() { + RedisDataStoreBuilder conf = Redis.dataStore(); + assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); + assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); + assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); + assertNull(conf.poolConfig); + } + + @Test + public void testPrefixConfigured() throws URISyntaxException { + RedisDataStoreBuilder conf = Redis.dataStore().prefix("prefix"); + assertEquals("prefix", conf.prefix); + } + + @Test + public void testConnectTimeoutConfigured() throws URISyntaxException { + RedisDataStoreBuilder conf = Redis.dataStore().connectTimeout(1, TimeUnit.SECONDS); + assertEquals(1000, conf.connectTimeout); + } + + @Test + public void testSocketTimeoutConfigured() throws URISyntaxException { + RedisDataStoreBuilder conf = Redis.dataStore().socketTimeout(1, TimeUnit.SECONDS); + assertEquals(1000, conf.socketTimeout); + } + + @Test + public void testPoolConfigConfigured() throws URISyntaxException { + JedisPoolConfig poolConfig = new JedisPoolConfig(); + RedisDataStoreBuilder conf = Redis.dataStore().poolConfig(poolConfig); + assertEquals(poolConfig, conf.poolConfig); + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java new file mode 100644 index 000000000..8e4f6ea1b --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java @@ -0,0 +1,62 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FeatureStoreCacheConfig; +import com.launchdarkly.client.FeatureStoreDatabaseTestBase; +import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; +import com.launchdarkly.client.utils.CachingStoreWrapper; + +import org.junit.BeforeClass; + +import java.net.URI; + +import static org.junit.Assume.assumeTrue; + +import redis.clients.jedis.Jedis; + +@SuppressWarnings("javadoc") +public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { + + private static final URI REDIS_URI = URI.create("redis://localhost:6379"); + + public RedisFeatureStoreTest(boolean cached) { + super(cached); + } + + @BeforeClass + public static void maybeSkipDatabaseTests() { + String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); + assumeTrue(skipParam == null || skipParam.equals("")); + } + + @Override + protected FeatureStore makeStore() { + RedisDataStoreBuilder builder = Redis.dataStore().uri(REDIS_URI); + builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); + return builder.createFeatureStore(); + } + + @Override + protected FeatureStore makeStoreWithPrefix(String prefix) { + return Redis.dataStore().uri(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).createFeatureStore(); + } + + @Override + protected void clearAllData() { + try (Jedis client = new Jedis("localhost")) { + client.flushDB(); + } + } + + @Override + protected boolean setUpdateHook(FeatureStore storeUnderTest, final Runnable hook) { + RedisDataStoreImpl core = (RedisDataStoreImpl)((CachingStoreWrapper)storeUnderTest).getCore(); + core.setUpdateListener(new UpdateListener() { + @Override + public void aboutToUpdate(String baseKey, String itemKey) { + hook.run(); + } + }); + return true; + } +} From 7884220cd108473596e003bb6c4572d9bf23b367 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 3 Jan 2020 20:38:13 -0800 Subject: [PATCH 218/327] deprecation --- 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 ada6651d1..9713704a7 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -13,6 +13,7 @@ * * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} */ +@Deprecated public class RedisFeatureStore implements FeatureStore { // The actual implementation is now in the com.launchdarkly.integrations package. This class remains // visible for backward compatibility, but simply delegates to an instance of the underlying store. From 7c9edc234ea00bc0acdcdabfbb354ce066d384af Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 3 Jan 2020 20:41:16 -0800 Subject: [PATCH 219/327] javadoc formatting --- .../java/com/launchdarkly/client/integrations/FileData.java | 6 +++--- .../java/com/launchdarkly/client/integrations/Redis.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/client/integrations/FileData.java index e2466c485..a6f65f3e2 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileData.java @@ -2,7 +2,7 @@ /** * Integration between the LaunchDarkly SDK and file data. - * + *

    * The file data source 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. See {@link #dataSource()} for details. @@ -14,11 +14,11 @@ public abstract class FileData { * Creates a {@link FileDataSourceBuilder} which you can use to configure the file data source. * This allows you to use local files as a source of feature flag state, instead of using an actual * LaunchDarkly connection. - * + *

    * This object can be modified with {@link FileDataSourceBuilder} methods for any desired * custom settings, before including it in the SDK configuration with * {@link com.launchdarkly.client.LDConfig.Builder#dataSource(com.launchdarkly.client.UpdateProcessorFactory)}. - * + *

    * At a minimum, you will want to call {@link FileDataSourceBuilder#filePaths(String...)} to specify * your data file(s); you can also use {@link FileDataSourceBuilder#autoUpdate(boolean)} to * specify that flags should be reloaded when a file is modified. See {@link FileDataSourceBuilder} diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java index 4b4351b9d..050b581bb 100644 --- a/src/main/java/com/launchdarkly/client/integrations/Redis.java +++ b/src/main/java/com/launchdarkly/client/integrations/Redis.java @@ -8,7 +8,7 @@ public abstract class Redis { /** * Returns a builder object for creating a Redis-backed data store. - * + *

    * This object can be modified with {@link RedisDataStoreBuilder} methods for any desired * custom settings, before including it in the SDK configuration with * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. From 150ec1e501069c60d9332ec497be45248c16c7b0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Jan 2020 12:07:39 -0800 Subject: [PATCH 220/327] hack pom to fix dependency scopes --- build.gradle | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index da1c4fd23..37d8767f7 100644 --- a/build.gradle +++ b/build.gradle @@ -385,7 +385,21 @@ publishing { pom.withXml { def root = asNode() root.appendNode('description', 'Official LaunchDarkly SDK for Java') - asNode().children().last() + pomConfig + + // The following workaround is for a known issue where the Shadow plugin assumes that all + // non-shaded dependencies should have "runtime" scope rather than "compile". + // https://github.com/johnrengelman/shadow/issues/321 + // https://github.com/launchdarkly/java-server-sdk/issues/151 + // Currently there doesn't seem to be any way way around this other than simply hacking the + // pom at this point after it has been generated by Shadow. All of the dependencies that + // are in the pom at this point should have compile scope. + def dependenciesNode = root.getAt('dependencies').get(0) + dependenciesNode.each { dependencyNode -> + def scopeNode = dependencyNode.getAt('scope').get(0) + scopeNode.setValue('compile') + } + + root.children().last() + pomConfig } } } From fa5fa9d3ac855f9b1ebb58666261cc60c853e79a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Jan 2020 12:36:48 -0800 Subject: [PATCH 221/327] upgrade Shadow plugin --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 37d8767f7..f4c16c321 100644 --- a/build.gradle +++ b/build.gradle @@ -88,7 +88,7 @@ buildscript { } dependencies { classpath 'org.ajoberstar:gradle-git:1.5.0-rc.1' - classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' + classpath 'com.github.jengelman.gradle.plugins:shadow:4.0.4' 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" From c754fdf0d475f88fbe452d9878eb54e10a3a6011 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Jan 2020 13:36:48 -0800 Subject: [PATCH 222/327] don't need fully qualified class name --- .../launchdarkly/client/integrations/FileDataSourceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java index 716b68e8a..0d933e967 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java @@ -35,7 +35,7 @@ public FileDataSourceTest() throws Exception { } private static FileDataSourceBuilder makeFactoryWithFile(Path path) { - return com.launchdarkly.client.integrations.FileData.dataSource().filePaths(path); + return FileData.dataSource().filePaths(path); } @Test From 7d35290d8402f23671742e0828529c3b145b0c8a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Jan 2020 17:06:34 -0800 Subject: [PATCH 223/327] typo --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92530cda2..16eb199e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the LaunchDarkly Java SDK will be documented in this file ## [4.10.1] - 2020-01-06 ### Fixed: -- The `pom.xml` dependencies were incorrectly specifying `runtime` scope rather than `compile`, causing problems for application that did not have their own dependencies on Gson and SLF4J. ([#151](https://github.com/launchdarkly/java-client/issues/151)) +- The `pom.xml` dependencies were incorrectly specifying `runtime` scope rather than `compile`, causing problems for applications that did not have their own dependencies on Gson and SLF4J. ([#151](https://github.com/launchdarkly/java-client/issues/151)) ## [4.10.0] - 2019-12-13 ### Added: From 68193f84bfb944bb2c14c93f63d079a3992ea4fa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Jan 2020 14:25:31 -0800 Subject: [PATCH 224/327] allow infinite cache TTL --- .../client/FeatureStoreCacheConfig.java | 32 ++- .../client/utils/CachingStoreWrapper.java | 113 ++++++--- .../client/utils/CachingStoreWrapperTest.java | 219 ++++++++++++++---- 3 files changed, 288 insertions(+), 76 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 9c615e4b1..ac00f0386 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -118,14 +118,31 @@ private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleVal /** * Returns true if caching will be enabled. - * @return true if the cache TTL is greater than 0 + * @return true if the cache TTL is non-zero */ public boolean isEnabled() { - return getCacheTime() > 0; + return getCacheTime() != 0; } /** - * Returns the cache TTL. Caching is enabled if this is greater than zero. + * Returns true if caching is enabled and does not have a finite TTL. + * @return true if the cache TTL is negative + */ + public boolean isInfiniteTtl() { + return getCacheTime() < 0; + } + + /** + * Returns the cache TTL. + *

    + * If the value is zero, caching is disabled. + *

    + * If the value is negative, data is cached forever (i.e. it will only be read again from the database + * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario + * where multiple processes are sharing the database, and the current process loses connectivity to + * LaunchDarkly while other processes are still receiving updates and writing them to the database, + * the current process will have stale data. + * * @return the cache TTL in whatever units were specified * @see #getCacheTimeUnit() */ @@ -143,6 +160,15 @@ public TimeUnit getCacheTimeUnit() { /** * Returns the cache TTL converted to milliseconds. + *

    + * If the value is zero, caching is disabled. + *

    + * If the value is negative, data is cached forever (i.e. it will only be read again from the database + * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario + * where multiple processes are sharing the database, and the current process loses connectivity to + * LaunchDarkly while other processes are still receiving updates and writing them to the database, + * the current process will have stale data. + * * @return the TTL in milliseconds */ public long getCacheTimeMillis() { diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index e2e5fa144..47de79688 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -5,6 +5,7 @@ import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheStats; import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; @@ -35,8 +36,9 @@ 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 FeatureStoreCacheConfig caching; private final LoadingCache> itemCache; - private final LoadingCache, Map> allCache; + private final LoadingCache, ImmutableMap> allCache; private final LoadingCache initCache; private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService executorService; @@ -52,6 +54,7 @@ public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching) { this.core = core; + this.caching = caching; if (!caching.isEnabled()) { itemCache = null; @@ -65,9 +68,9 @@ public Optional load(CacheKey key) throws Exception { return Optional.fromNullable(core.getInternal(key.kind, key.key)); } }; - CacheLoader, Map> allLoader = new CacheLoader, Map>() { + CacheLoader, ImmutableMap> allLoader = new CacheLoader, ImmutableMap>() { @Override - public Map load(VersionedDataKind kind) throws Exception { + public ImmutableMap load(VersionedDataKind kind) throws Exception { return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); } }; @@ -78,17 +81,18 @@ public Boolean load(String key) throws Exception { } }; - switch (caching.getStaleValuesPolicy()) { - case EVICT: + if (caching.isInfiniteTtl()) { + itemCache = CacheBuilder.newBuilder().build(itemLoader); + allCache = CacheBuilder.newBuilder().build(allLoader); + executorService = null; + } else if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.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: + } else { // 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, @@ -105,7 +109,11 @@ public Boolean load(String key) throws Exception { allCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(allLoader); } - initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); + if (caching.isInfiniteTtl()) { + initCache = CacheBuilder.newBuilder().build(initLoader); + } else { + initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); + } } } @@ -146,19 +154,34 @@ 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); + try { + core.initInternal(castMap); + } catch (RuntimeException e) { + // Normally, if the underlying store failed to do the update, we do not want to update the cache - + // the idea being that it's better to stay in a consistent state of having old data than to act + // like we have new data but then suddenly fall back to old data when the cache expires. However, + // if the cache TTL is infinite, then it makes sense to update the cache always. + if (allCache != null && itemCache != null && caching.isInfiniteTtl()) { + updateAllCache(castMap); + inited.set(true); + } + throw e; + } - inited.set(true); - if (allCache != null && itemCache != null) { allCache.invalidateAll(); itemCache.invalidateAll(); - for (Map.Entry, Map> e0: castMap.entrySet()) { - VersionedDataKind kind = e0.getKey(); - allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); - for (Map.Entry e1: e0.getValue().entrySet()) { - itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); - } + updateAllCache(castMap); + } + inited.set(true); + } + + private void updateAllCache(Map, Map> allData) { + for (Map.Entry, Map> e0: allData.entrySet()) { + VersionedDataKind kind = e0.getKey(); + allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); + for (Map.Entry e1: e0.getValue().entrySet()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); } } } @@ -170,15 +193,49 @@ public void delete(VersionedDataKind kind, String k @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)); + VersionedData newState = item; + RuntimeException failure = null; + try { + newState = core.upsertInternal(kind, item); + } catch (RuntimeException e) { + failure = e; } - if (allCache != null) { - allCache.invalidate(kind); + // Normally, if the underlying store failed to do the update, we do not want to update the cache - + // the idea being that it's better to stay in a consistent state of having old data than to act + // like we have new data but then suddenly fall back to old data when the cache expires. However, + // if the cache TTL is infinite, then it makes sense to update the cache always. + if (failure == null || caching.isInfiniteTtl()) { + if (itemCache != null) { + itemCache.put(CacheKey.forItem(kind, item.getKey()), Optional.fromNullable(newState)); + } + if (allCache != null) { + // If the cache has a finite TTL, then we should remove the "all items" cache entry to force + // a reread the next time All is called. However, if it's an infinite TTL, we need to just + // update the item within the existing "all items" entry (since we want things to still work + // even if the underlying store is unavailable). + if (caching.isInfiniteTtl()) { + try { + ImmutableMap cachedAll = allCache.get(kind); + Map newValues = new HashMap<>(); + newValues.putAll(cachedAll); + newValues.put(item.getKey(), newState); + allCache.put(kind, ImmutableMap.copyOf(newValues)); + } catch (Exception e) { + // An exception here means that we did not have a cached value for All, so it tried to query + // the underlying store, which failed (not surprisingly since it just failed a moment ago + // when we tried to do an update). This should not happen in infinite-cache mode, but if it + // does happen, there isn't really anything we can do. + } + } else { + allCache.invalidate(kind); + } + } + } + if (failure != null) { + throw failure; } } - + @Override public boolean initialized() { if (inited.get()) { @@ -222,16 +279,16 @@ private VersionedData itemOnlyIfNotDeleted(VersionedData item) { } @SuppressWarnings("unchecked") - private Map itemsOnlyIfNotDeleted(Map items) { - Map ret = new HashMap<>(); + private ImmutableMap itemsOnlyIfNotDeleted(Map items) { + ImmutableMap.Builder builder = ImmutableMap.builder(); if (items != null) { for (Map.Entry item: items.entrySet()) { if (!item.getValue().isDeleted()) { - ret.put(item.getKey(), (T) item.getValue()); + builder.put(item.getKey(), (T) item.getValue()); } } } - return ret; + return builder.build(); } private static class CacheKey { diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index ff9a75d6a..6419b6db1 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -5,6 +5,7 @@ import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -22,23 +23,46 @@ import static org.hamcrest.Matchers.nullValue; import static org.junit.Assume.assumeThat; +@SuppressWarnings("javadoc") @RunWith(Parameterized.class) public class CachingStoreWrapperTest { - private final boolean cached; + private final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); + + private final CachingMode cachingMode; private final MockCore core; private final CachingStoreWrapper wrapper; + static enum CachingMode { + UNCACHED, + CACHED_WITH_FINITE_TTL, + CACHED_INDEFINITELY; + + FeatureStoreCacheConfig toCacheConfig() { + switch (this) { + case CACHED_WITH_FINITE_TTL: + return FeatureStoreCacheConfig.enabled().ttlSeconds(30); + case CACHED_INDEFINITELY: + return FeatureStoreCacheConfig.enabled().ttlSeconds(-1); + default: + return FeatureStoreCacheConfig.disabled(); + } + } + + boolean isCached() { + return this != UNCACHED; + } + }; + @Parameters(name="cached={0}") - public static Iterable data() { - return Arrays.asList(new Boolean[] { false, true }); + public static Iterable data() { + return Arrays.asList(CachingMode.values()); } - public CachingStoreWrapperTest(boolean cached) { - this.cached = cached; + public CachingStoreWrapperTest(CachingMode cachingMode) { + this.cachingMode = cachingMode; this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : - FeatureStoreCacheConfig.disabled()); + this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig()); } @Test @@ -51,7 +75,7 @@ public void get() { 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 + assertThat(result, equalTo(cachingMode.isCached() ? itemv1 : itemv2)); // if cached, we will not see the new underlying value yet } @Test @@ -64,7 +88,7 @@ public void getDeletedItem() { 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 + assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv2)); // if cached, we will not see the new underlying value yet } @Test @@ -75,14 +99,12 @@ public void getMissingItem() { 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 + assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result } @Test public void cachedGetUsesValuesFromInit() { - if (!cached) { - return; - } + assumeThat(cachingMode.isCached(), is(true)); MockItem item1 = new MockItem("flag1", 1, false); MockItem item2 = new MockItem("flag2", 1, false); @@ -107,7 +129,7 @@ public void getAll() { core.forceRemove(THINGS, item2.key); items = wrapper.all(THINGS); - if (cached) { + if (cachingMode.isCached()) { assertThat(items, equalTo(expected)); } else { Map expected1 = ImmutableMap.of(item1.key, item1); @@ -129,9 +151,7 @@ public void getAllRemovesDeletedItems() { @Test public void cachedAllUsesValuesFromInit() { - if (!cached) { - return; - } + assumeThat(cachingMode.isCached(), is(true)); MockItem item1 = new MockItem("flag1", 1, false); MockItem item2 = new MockItem("flag2", 1, false); @@ -144,33 +164,44 @@ public void cachedAllUsesValuesFromInit() { Map expected = ImmutableMap.of(item1.key, item1, item2.key, item2); assertThat(items, equalTo(expected)); } - + @Test - public void cachedAllUsesFreshValuesIfThereHasBeenAnUpdate() { - if (!cached) { - return; - } + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreInitFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - 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); + MockItem item = new MockItem("flag", 1, false); - Map, Map> allData = makeData(item1, item2); - wrapper.init(allData); + core.fakeError = FAKE_ERROR; + try { + wrapper.init(makeData(item)); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(wrapper.all(THINGS).size(), equalTo(0)); + } - // make a change to item1 via the wrapper - this should flush the cache - wrapper.upsert(THINGS, item1v2); + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - // make a change to item2 that bypasses the cache - core.forceSet(THINGS, item2v2); + MockItem item = new MockItem("flag", 1, false); - // 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)); + core.fakeError = FAKE_ERROR; + try { + wrapper.init(makeData(item)); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + Map expected = ImmutableMap.of(item.key, item); + assertThat(wrapper.all(THINGS), equalTo(expected)); } - + @Test public void upsertSuccessful() { MockItem itemv1 = new MockItem("flag", 1, false); @@ -184,7 +215,7 @@ public void upsertSuccessful() { // 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) { + if (cachingMode.isCached()) { MockItem item1v3 = new MockItem("flag", 3, false); core.forceSet(THINGS, item1v3); } @@ -194,9 +225,7 @@ public void upsertSuccessful() { @Test public void cachedUpsertUnsuccessful() { - if (!cached) { - return; - } + assumeThat(cachingMode.isCached(), is(true)); // 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 @@ -217,6 +246,94 @@ public void cachedUpsertUnsuccessful() { assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); } + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreUpdateFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); + + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, false); + + wrapper.init(makeData(itemv1)); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(THINGS, itemv2); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv1)); // cache still has old item, same as underlying store + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreUpdateFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); + + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, false); + + wrapper.init(makeData(itemv1)); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(THINGS, itemv2); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); // underlying store has old item but cache has new item + } + + @Test + public void cachedStoreWithFiniteTtlRemovesCachedAllDataIfOneItemIsUpdated() { + assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); + + MockItem item1v1 = new MockItem("item1", 1, false); + MockItem item1v2 = new MockItem("item1", 2, false); + MockItem item2v1 = new MockItem("item2", 1, false); + MockItem item2v2 = new MockItem("item2", 2, false); + + wrapper.init(makeData(item1v1, item2v1)); + wrapper.all(THINGS); // now the All data is cached + + // do an upsert for item1 - this should drop the previous all() data from the cache + wrapper.upsert(THINGS, item1v2); + + // modify item2 directly in the underlying data + core.forceSet(THINGS, item2v2); + + // now, all() should reread the underlying data so we see both changes + Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v2); + assertThat(wrapper.all(THINGS), equalTo(expected)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCachedAllDataIfOneItemIsUpdated() { + assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); + + MockItem item1v1 = new MockItem("item1", 1, false); + MockItem item1v2 = new MockItem("item1", 2, false); + MockItem item2v1 = new MockItem("item2", 1, false); + MockItem item2v2 = new MockItem("item2", 2, false); + + wrapper.init(makeData(item1v1, item2v1)); + wrapper.all(THINGS); // now the All data is cached + + // do an upsert for item1 - this should update the underlying data *and* the cached all() data + wrapper.upsert(THINGS, item1v2); + + // modify item2 directly in the underlying data + core.forceSet(THINGS, item2v2); + + // now, all() should *not* reread the underlying data - we should only see the change to item1 + Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v1); + assertThat(wrapper.all(THINGS), equalTo(expected)); + } + @Test public void delete() { MockItem itemv1 = new MockItem("flag", 1, false); @@ -234,12 +351,12 @@ public void delete() { core.forceSet(THINGS, itemv3); MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, cached ? nullValue(MockItem.class) : equalTo(itemv3)); + assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv3)); } @Test public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { - assumeThat(cached, is(false)); + assumeThat(cachingMode.isCached(), is(false)); assertThat(wrapper.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -255,7 +372,7 @@ public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { @Test public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { - assumeThat(cached, is(false)); + assumeThat(cachingMode.isCached(), is(false)); assertThat(wrapper.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -268,7 +385,7 @@ public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { @Test public void initializedCanCacheFalseResult() throws Exception { - assumeThat(cached, is(true)); + assumeThat(cachingMode.isCached(), 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, FeatureStoreCacheConfig.enabled().ttlMillis(500))) { @@ -303,6 +420,7 @@ static class MockCore implements FeatureStoreCore { Map, Map> data = new HashMap<>(); boolean inited; int initedQueryCount; + RuntimeException fakeError; @Override public void close() throws IOException { @@ -310,6 +428,7 @@ public void close() throws IOException { @Override public VersionedData getInternal(VersionedDataKind kind, String key) { + maybeThrow(); if (data.containsKey(kind)) { return data.get(kind).get(key); } @@ -318,11 +437,13 @@ public VersionedData getInternal(VersionedDataKind kind, String key) { @Override public Map getAllInternal(VersionedDataKind kind) { + maybeThrow(); return data.get(kind); } @Override public void initInternal(Map, Map> allData) { + maybeThrow(); data.clear(); for (Map.Entry, Map> entry: allData.entrySet()) { data.put(entry.getKey(), new LinkedHashMap(entry.getValue())); @@ -332,6 +453,7 @@ public void initInternal(Map, Map> a @Override public VersionedData upsertInternal(VersionedDataKind kind, VersionedData item) { + maybeThrow(); if (!data.containsKey(kind)) { data.put(kind, new HashMap()); } @@ -346,6 +468,7 @@ public VersionedData upsertInternal(VersionedDataKind kind, VersionedData ite @Override public boolean initializedInternal() { + maybeThrow(); initedQueryCount++; return inited; } @@ -363,6 +486,12 @@ public void forceRemove(VersionedDataKind kind, String key) { data.get(kind).remove(key); } } + + private void maybeThrow() { + if (fakeError != null) { + throw fakeError; + } + } } static class MockItem implements VersionedData { From 526939cf053363bda50f1f5775f93b5c895affeb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Jan 2020 14:30:13 -0800 Subject: [PATCH 225/327] fix test --- .../com/launchdarkly/client/FeatureStoreCachingTest.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java index f8d15f517..c9259b622 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java @@ -10,12 +10,14 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +@SuppressWarnings("javadoc") public class FeatureStoreCachingTest { @Test public void disabledHasExpectedProperties() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.disabled(); assertThat(fsc.getCacheTime(), equalTo(0L)); assertThat(fsc.isEnabled(), equalTo(false)); + assertThat(fsc.isInfiniteTtl(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -25,6 +27,7 @@ public void enabledHasExpectedProperties() { assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); assertThat(fsc.isEnabled(), equalTo(true)); + assertThat(fsc.isInfiniteTtl(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -72,13 +75,15 @@ public void zeroTtlMeansDisabled() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(0, TimeUnit.SECONDS); assertThat(fsc.isEnabled(), equalTo(false)); + assertThat(fsc.isInfiniteTtl(), equalTo(false)); } @Test - public void negativeTtlMeansDisabled() { + public void negativeTtlMeansEnabledAndInfinite() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(-1, TimeUnit.SECONDS); - assertThat(fsc.isEnabled(), equalTo(false)); + assertThat(fsc.isEnabled(), equalTo(true)); + assertThat(fsc.isInfiniteTtl(), equalTo(true)); } @Test From 2942a488f914d84508640ba98a8db71730b0920a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 7 Jan 2020 15:57:19 -0800 Subject: [PATCH 226/327] fix comment --- .../com/launchdarkly/client/FeatureStoreCacheConfig.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index ac00f0386..9cfedf383 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -187,6 +187,15 @@ public StaleValuesPolicy getStaleValuesPolicy() { * 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. + * after this amount of time from the time when they were originally cached. + *

    + * If the value is zero, caching is disabled. + *

    + * If the value is negative, data is cached forever (i.e. it will only be read again from the database + * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario + * where multiple processes are sharing the database, and the current process loses connectivity to + * LaunchDarkly while other processes are still receiving updates and writing them to the database, + * the current process will have stale data. * * @param cacheTime the cache TTL in whatever units you wish * @param timeUnit the time unit From 314686b63ec1352f08832d5ec7735a7446e0a0c6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 15:45:51 -0800 Subject: [PATCH 227/327] preserve exception in evaluation reason --- .../launchdarkly/client/EvaluationReason.java | 48 ++++++++++++++----- .../com/launchdarkly/client/LDClient.java | 4 +- .../client/LDClientEvaluationTest.java | 5 +- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index f6c91bee8..8d277e443 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -78,18 +78,18 @@ public static enum ErrorKind { WRONG_TYPE, /** * Indicates that an unexpected exception stopped flag evaluation. An error message will always be logged - * in this case. + * in this case, and the exception should be available via {@link Error#getException()}. */ EXCEPTION } // static instances to avoid repeatedly allocating reasons for the same errors - private static final Error ERROR_CLIENT_NOT_READY = new Error(ErrorKind.CLIENT_NOT_READY); - private static final Error ERROR_FLAG_NOT_FOUND = new Error(ErrorKind.FLAG_NOT_FOUND); - private static final Error ERROR_MALFORMED_FLAG = new Error(ErrorKind.MALFORMED_FLAG); - private static final Error ERROR_USER_NOT_SPECIFIED = new Error(ErrorKind.USER_NOT_SPECIFIED); - private static final Error ERROR_WRONG_TYPE = new Error(ErrorKind.WRONG_TYPE); - private static final Error ERROR_EXCEPTION = new Error(ErrorKind.EXCEPTION); + private static final Error ERROR_CLIENT_NOT_READY = new Error(ErrorKind.CLIENT_NOT_READY, null); + private static final Error ERROR_FLAG_NOT_FOUND = new Error(ErrorKind.FLAG_NOT_FOUND, null); + private static final Error ERROR_MALFORMED_FLAG = new Error(ErrorKind.MALFORMED_FLAG, null); + private static final Error ERROR_USER_NOT_SPECIFIED = new Error(ErrorKind.USER_NOT_SPECIFIED, null); + private static final Error ERROR_WRONG_TYPE = new Error(ErrorKind.WRONG_TYPE, null); + private static final Error ERROR_EXCEPTION = new Error(ErrorKind.EXCEPTION, null); private final Kind kind; @@ -168,9 +168,19 @@ public static Error error(ErrorKind errorKind) { case MALFORMED_FLAG: return ERROR_MALFORMED_FLAG; case USER_NOT_SPECIFIED: return ERROR_USER_NOT_SPECIFIED; case WRONG_TYPE: return ERROR_WRONG_TYPE; - default: return new Error(errorKind); + default: return new Error(errorKind, null); } } + + /** + * Returns an instance of {@link Error} with the kind {@link ErrorKind#EXCEPTION} and an exception instance. + * @param exception the exception that caused the error + * @return a reason object + * @since 4.11.0 + */ + public static Error exception(Exception exception) { + return new Error(ErrorKind.EXCEPTION, exception); + } /** * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned @@ -307,11 +317,13 @@ private Fallthrough() */ public static class Error extends EvaluationReason { private final ErrorKind errorKind; + private final Exception exception; - private Error(ErrorKind errorKind) { + private Error(ErrorKind errorKind, Exception exception) { super(Kind.ERROR); checkNotNull(errorKind); this.errorKind = errorKind; + this.exception = exception; } /** @@ -322,19 +334,31 @@ public ErrorKind getErrorKind() { return errorKind; } + /** + * Returns the exception that caused the error condition, if applicable. + *

    + * This is only set if {@link #getErrorKind()} is {@link ErrorKind#EXCEPTION}. + * + * @return the exception instance + * @since 4.11.0 + */ + public Exception getException() { + return exception; + } + @Override public boolean equals(Object other) { - return other instanceof Error && errorKind == ((Error) other).errorKind; + return other instanceof Error && errorKind == ((Error) other).errorKind && Objects.equals(exception, ((Error) other).exception); } @Override public int hashCode() { - return errorKind.hashCode(); + return Objects.hash(errorKind, exception); } @Override public String toString() { - return getKind().name() + "(" + errorKind.name() + ")"; + return getKind().name() + "(" + errorKind.name() + (exception == null ? "" : ("," + exception)) + ")"; } } } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 5bf3e4c02..676c518ed 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -203,7 +203,7 @@ public FeatureFlagsState allFlagsState(LDUser user, FlagsStateOption... options) } catch (Exception 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(), EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, LDValue.ofNull())); + builder.addFlag(entry.getValue(), EvaluationDetail.fromValue(LDValue.ofNull(), null, EvaluationReason.exception(e))); } } return builder.build(); @@ -375,7 +375,7 @@ private EvaluationDetail evaluateInternal(String featureKey, LDUser use sendFlagRequestEvent(eventFactory.newDefaultFeatureRequestEvent(featureFlag, user, defaultValue, EvaluationReason.ErrorKind.EXCEPTION)); } - return EvaluationDetail.error(EvaluationReason.ErrorKind.EXCEPTION, defaultValue); + return EvaluationDetail.fromValue(defaultValue, null, EvaluationReason.exception(e)); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index ff41ad1c1..783eadd1a 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -261,7 +261,8 @@ public void appropriateErrorIfValueWrongType() throws Exception { @Test public void appropriateErrorForUnexpectedException() throws Exception { - FeatureStore badFeatureStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + RuntimeException exception = new RuntimeException("sorry"); + FeatureStore badFeatureStore = featureStoreThatThrowsException(exception); LDConfig badConfig = new LDConfig.Builder() .dataStore(specificFeatureStore(badFeatureStore)) .eventProcessorFactory(Components.nullEventProcessor()) @@ -269,7 +270,7 @@ public void appropriateErrorForUnexpectedException() throws Exception { .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, - EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION)); + EvaluationReason.exception(exception)); assertEquals(expectedResult, badClient.boolVariationDetail("key", user, false)); } } From 8989062555e23083a4ee060bab7301b9f0ace83b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 15:55:38 -0800 Subject: [PATCH 228/327] javadoc fix --- 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 8d277e443..dd3c4a248 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -78,7 +78,7 @@ public static enum ErrorKind { WRONG_TYPE, /** * Indicates that an unexpected exception stopped flag evaluation. An error message will always be logged - * in this case, and the exception should be available via {@link Error#getException()}. + * in this case, and the exception should be available via {@link EvaluationReason.Error#getException()}. */ EXCEPTION } From f7ba863dadf9eddf142b42fdbed2e532f1fb9049 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 17:18:19 -0800 Subject: [PATCH 229/327] implement new DataStore API and fix cache stats logic --- .../com/launchdarkly/client/Components.java | 31 +++ .../client/FeatureStoreCacheConfig.java | 19 ++ .../client/RedisFeatureStoreBuilder.java | 7 + .../client/integrations/CacheMonitor.java | 146 ++++++++++++ .../PersistentDataStoreBuilder.java | 216 ++++++++++++++++++ .../integrations/RedisDataStoreBuilder.java | 26 ++- .../PersistentDataStoreFactory.java | 53 +++++ .../client/utils/CachingStoreWrapper.java | 94 +++++--- .../DeprecatedRedisFeatureStoreTest.java | 36 +++ .../client/utils/CachingStoreWrapperTest.java | 54 ++++- 10 files changed, 647 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index fb48bc1dd..fbc03a0fb 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -1,5 +1,8 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +32,34 @@ public abstract class Components { public static FeatureStoreFactory inMemoryDataStore() { return inMemoryFeatureStoreFactory; } + + /** + * Returns a configurable factory for some implementation of a persistent data store. + *

    + * This method is used in conjunction with another factory object provided by specific components + * such as the Redis integration. The latter provides builder methods for options that are specific + * to that integration, while the {@link PersistentDataStoreBuilder} provides options like + * that are + * applicable to any persistent data store (such as caching). For example: + * + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .dataStore(
    +   *             Components.persistentDataStore(
    +   *                 Redis.dataStore().url("redis://my-redis-host")
    +   *             ).ttlSeconds(15)
    +   *         )
    +   *         .build();
    +   * 
    + * + * See {@link PersistentDataStoreBuilder} for more on how this method is used. + * + * @param storeFactory the factory/builder for the specific kind of persistent data store + * @return a {@link PersistentDataStoreBuilder} + */ + public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { + return new PersistentDataStoreBuilder(storeFactory); + } /** * Deprecated name for {@link #inMemoryDataStore()}. diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 9cfedf383..65451b712 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.common.cache.CacheBuilder; +import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; import java.util.Objects; import java.util.concurrent.TimeUnit; @@ -25,7 +26,9 @@ * * @see RedisFeatureStoreBuilder#caching(FeatureStoreCacheConfig) * @since 4.6.0 + * @deprecated This has been superseded by the {@link PersistentDataStoreBuilder} interface. */ +@Deprecated public final class FeatureStoreCacheConfig { /** * The default TTL, in seconds, used by {@link #DEFAULT}. @@ -236,6 +239,22 @@ public FeatureStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { return new FeatureStoreCacheConfig(cacheTime, cacheTimeUnit, policy); } + /** + * Used internally for backward compatibility from the newer builder API. + * + * @param policy a {@link com.launchdarkly.client.integrations.PersistentDataStoreBuilder.StaleValuesPolicy} constant + * @return an updated parameters object + */ + public FeatureStoreCacheConfig staleValuesPolicy(PersistentDataStoreBuilder.StaleValuesPolicy policy) { + switch (policy) { + case REFRESH: + return staleValuesPolicy(StaleValuesPolicy.REFRESH); + case REFRESH_ASYNC: + return staleValuesPolicy(StaleValuesPolicy.REFRESH_ASYNC); + default: + return staleValuesPolicy(StaleValuesPolicy.EVICT); + } + } @Override public boolean equals(Object other) { if (other instanceof FeatureStoreCacheConfig) { diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index d84e3aa58..07e8684aa 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.CacheMonitor; import com.launchdarkly.client.integrations.Redis; import com.launchdarkly.client.integrations.RedisDataStoreBuilder; @@ -48,6 +49,12 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { // These constructors are called only from Components RedisFeatureStoreBuilder() { wrappedBuilder = Redis.dataStore(); + + // In order to make the cacheStats() method on the deprecated RedisFeatureStore class work, we need to + // turn on cache monitoring. In the newer API, cache monitoring would only be turned on if the application + // specified its own CacheMonitor, but in the deprecated API there's no way to know if they will want the + // statistics or not. + wrappedBuilder.cacheMonitor(new CacheMonitor()); } RedisFeatureStoreBuilder(URI uri) { diff --git a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java new file mode 100644 index 000000000..67e8e7d4b --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java @@ -0,0 +1,146 @@ +package com.launchdarkly.client.integrations; + +import java.util.Objects; +import java.util.concurrent.Callable; + +/** + * A conduit that an application can use to monitor caching behavior of a persistent data store. + *

    + * See {@link PersistentDataStoreBuilder#cacheMonitor(CacheMonitor)} + * @since 4.11.0 + */ +public final class CacheMonitor { + private Callable source; + + /** + * Constructs a new instance. + */ + public CacheMonitor() {} + + /** + * Called internally by the SDK to establish a source for the statistics. + * @param source provided by an internal SDK component + */ + public void setSource(Callable source) { + this.source = source; + } + + /** + * Queries the current cache statistics. + * + * @return a {@link CacheStats} instance, or null if not available + */ + public CacheStats getCacheStats() { + try { + return source == null ? null : source.call(); + } catch (Exception e) { + return null; + } + } + + /** + * A snapshot of cache statistics. The statistics are cumulative across the lifetime of the data store. + *

    + * This is based on the data provided by Guava's caching framework. The SDK currently uses Guava + * internally, but is not guaranteed to always do so, and to avoid embedding Guava API details in + * the SDK API this is provided as a separate class. + */ + public static final class CacheStats { + private final long hitCount; + private final long missCount; + private final long loadSuccessCount; + private final long loadExceptionCount; + private final long totalLoadTime; + private final long evictionCount; + + /** + * Constructs a new instance. + * + * @param hitCount + * @param missCount + * @param loadSuccessCount + * @param loadExceptionCount + * @param totalLoadTime + * @param evictionCount + */ + public CacheStats(long hitCount, long missCount, long loadSuccessCount, long loadExceptionCount, + long totalLoadTime, long evictionCount) { + this.hitCount = hitCount; + this.missCount = missCount; + this.loadSuccessCount = loadSuccessCount; + this.loadExceptionCount = loadExceptionCount; + this.totalLoadTime = totalLoadTime; + this.evictionCount = evictionCount; + } + + /** + * The number of data queries that received cached data instead of going to the underlying data store. + * @return the number of cache hits + */ + public long getHitCount() { + return hitCount; + } + + /** + * The number of data queries that did not find cached data and went to the underlying data store. + * @return the number of cache misses + */ + public long getMissCount() { + return missCount; + } + + /** + * The number of times a cache miss resulted in successfully loading a data store item (or finding + * that it did not exist in the store). + * @return the number of successful loads + */ + public long getLoadSuccessCount() { + return loadSuccessCount; + } + + /** + * The number of times that an error occurred while querying the underlying data store. + * @return the number of failed loads + */ + public long getLoadExceptionCount() { + return loadExceptionCount; + } + + /** + * The total number of nanoseconds that the cache has spent loading new values. + * @return total time spent for all cache loads + */ + public long getTotalLoadTime() { + return totalLoadTime; + } + + /** + * The number of times cache entries have been evicted. + * @return the number of evictions + */ + public long getEvictionCount() { + return evictionCount; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof CacheStats)) { + return false; + } + CacheStats o = (CacheStats)other; + return hitCount == o.hitCount && missCount == o.missCount && loadSuccessCount == o.loadSuccessCount && + loadExceptionCount == o.loadExceptionCount && totalLoadTime == o.totalLoadTime && evictionCount == o.evictionCount; + } + + @Override + public int hashCode() { + return Objects.hash(hitCount, missCount, loadSuccessCount, loadExceptionCount, totalLoadTime, evictionCount); + } + + @Override + public String toString() { + return "{hit=" + hitCount + ", miss=" + missCount + ", loadSuccess=" + loadSuccessCount + + ", loadException=" + loadExceptionCount + ", totalLoadTime=" + totalLoadTime + ", evictionCount=" + evictionCount + "}"; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java new file mode 100644 index 000000000..6f69ac4a5 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java @@ -0,0 +1,216 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FeatureStoreFactory; +import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; + +import java.util.concurrent.TimeUnit; + +/** + * A configurable factory for a persistent data store. + *

    + * Several database integrations exist for the LaunchDarkly SDK, each with its own behavior and options + * specific to that database; this is described via some implementation of {@link PersistentDataStoreFactory}. + * There is also universal behavior that the SDK provides for all persistent data stores, such as caching; + * the {@link PersistentDataStoreBuilder} adds this. + *

    + * An example of using + *

    + * options that are specific to that implementation; the builder implements {@link PersistentDataStoreFactory}. + * Then call {@link Components#persistentDataStore(PersistentDataStoreFactory)} to wrap that object in the + * standard SDK persistent data store behavior, in the form of a {@link PersistentDataStoreBuilder} + * which can be configured with caching options. Finally, pass this to + * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(FeatureStoreFactory)}. + * For example, using the Redis integration: + * + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .dataStore(
    +   *             Components.persistentDataStore(
    +   *                 Redis.dataStore().url("redis://my-redis-host")
    +   *             ).cacheSeconds(15)
    +   *         )
    +   *         .build();
    +   * 
    + * + * In this example, {@code .url()} is an option specifically for the Redis integration, whereas + * {@code ttlSeconds()} is an option that can be used for any persistent data store. + * + * @since 4.11.0 + */ +public final class PersistentDataStoreBuilder implements FeatureStoreFactory { + /** + * The default value for the cache TTL. + */ + public static final int DEFAULT_CACHE_TTL_SECONDS = 15; + + private final PersistentDataStoreFactory persistentDataStoreFactory; + + /** + * Possible values for {@link #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 com.google.common.cache.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 + }; + + /** + * Creates a new builder. + * + * @param persistentDataStoreFactory the factory implementation for the specific data store type + */ + public PersistentDataStoreBuilder(PersistentDataStoreFactory persistentDataStoreFactory) { + this.persistentDataStoreFactory = persistentDataStoreFactory; + } + + @Override + public FeatureStore createFeatureStore() { + return persistentDataStoreFactory.createFeatureStore(); + } + + /** + * Specifies that the SDK should not use an in-memory cache for the persistent data store. + * This means that every feature flag evaluation will trigger a data store query. + * + * @return the builder + */ + public PersistentDataStoreBuilder noCaching() { + return cacheTime(0, TimeUnit.MILLISECONDS); + } + + /** + * Specifies the cache TTL. Items will be evicted or refreshed (depending on the StaleValuesPolicy) + * after this amount of time from the time when they were originally cached. + *

    + * If the value is zero, caching is disabled (equivalent to {@link #noCaching()}). + *

    + * If the value is negative, data is cached forever (equivalent to {@link #cacheForever()}). + * + * @param cacheTime the cache TTL in whatever units you wish + * @param cacheTimeUnit the time unit + * @return the builder + */ + @SuppressWarnings("deprecation") + public PersistentDataStoreBuilder cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { + persistentDataStoreFactory.cacheTime(cacheTime, cacheTimeUnit); + return this; + } + + /** + * Shortcut for calling {@link #cacheTime(long, TimeUnit)} with {@link TimeUnit#MILLISECONDS}. + * + * @param millis the cache TTL in milliseconds + * @return the builder + */ + public PersistentDataStoreBuilder cacheTtlMillis(long millis) { + return cacheTime(millis, TimeUnit.MILLISECONDS); + } + + /** + * Shortcut for calling {@link #cacheTime(long, TimeUnit)} with {@link TimeUnit#SECONDS}. + * + * @param seconds the cache TTL in seconds + * @return the builder + */ + public PersistentDataStoreBuilder cacheTtlSeconds(long seconds) { + return cacheTime(seconds, TimeUnit.SECONDS); + } + + /** + * Specifies that the in-memory cache should never expire. In this mode, data will be written + * to both the underlying persistent store and the cache, but will only ever be read from + * persistent store if the SDK is restarted. + *

    + * Use this mode with caution: it means that in a scenario where multiple processes are sharing + * the database, and the current process loses connectivity to LaunchDarkly while other processes + * are still receiving updates and writing them to the database, the current process will have + * stale data. + * + * @return the builder + */ + public PersistentDataStoreBuilder cacheForever() { + return cacheTime(-1, TimeUnit.MILLISECONDS); + } + + /** + * 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 staleValuesPolicy a {@link StaleValuesPolicy} constant + * @return the builder + */ + @SuppressWarnings("deprecation") + public PersistentDataStoreBuilder staleValuesPolicy(StaleValuesPolicy staleValuesPolicy) { + persistentDataStoreFactory.staleValuesPolicy(staleValuesPolicy); + return this; + } + + /** + * Provides a conduit for an application to monitor the effectiveness of the in-memory cache. + *

    + * Create an instance of {@link CacheMonitor}; retain a reference to it, and also pass it to this + * method when you are configuring the persistent data store. The store will use + * {@link CacheMonitor#setSource(java.util.concurrent.Callable)} to make the caching + * statistics available through that {@link CacheMonitor} instance. + *

    + * Note that turning on cache monitoring may slightly decrease performance, due to the need to + * record statistics for each cache operation. + *

    + * Example usage: + * + *

    
    +   *     CacheMonitor cacheMonitor = new CacheMonitor();
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .dataStore(Components.persistentDataStore(Redis.dataStore()).cacheMonitor(cacheMonitor))
    +   *         .build();
    +   *     // later...
    +   *     CacheMonitor.CacheStats stats = cacheMonitor.getCacheStats();
    +   * 
    + * + * @param cacheMonitor an instance of {@link CacheMonitor} + * @return the builder + */ + @SuppressWarnings("deprecation") + public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { + persistentDataStoreFactory.cacheMonitor(cacheMonitor); + return this; + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index bbc50491d..605bc4f60 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -2,7 +2,8 @@ import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.FeatureStoreFactory; +import com.launchdarkly.client.integrations.PersistentDataStoreBuilder.StaleValuesPolicy; +import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; import com.launchdarkly.client.utils.CachingStoreWrapper; import java.net.URI; @@ -36,7 +37,8 @@ * * @since 4.11.0 */ -public final class RedisDataStoreBuilder implements FeatureStoreFactory { +@SuppressWarnings("deprecation") +public final class RedisDataStoreBuilder implements PersistentDataStoreFactory { /** * The default value for the Redis URI: {@code redis://localhost:6379} */ @@ -55,6 +57,7 @@ public final class RedisDataStoreBuilder implements FeatureStoreFactory { String password = null; boolean tls = false; FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + CacheMonitor cacheMonitor = null; JedisPoolConfig poolConfig = null; // These constructors are called only from Implementations @@ -122,12 +125,29 @@ public RedisDataStoreBuilder uri(URI redisUri) { * * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters * @return the builder + * @deprecated This has been superseded by the {@link PersistentDataStoreBuilder} interface. */ + @Deprecated public RedisDataStoreBuilder caching(FeatureStoreCacheConfig caching) { this.caching = caching; return this; } + @Override + public void cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { + this.caching = caching.ttl(cacheTime, cacheTimeUnit); + } + + @Override + public void staleValuesPolicy(StaleValuesPolicy staleValuesPolicy) { + this.caching = caching.staleValuesPolicy(staleValuesPolicy); + } + + @Override + public void cacheMonitor(CacheMonitor cacheMonitor) { + this.cacheMonitor = cacheMonitor; + } + /** * Optionally configures the namespace prefix for all keys stored in Redis. * @@ -182,6 +202,6 @@ public RedisDataStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) */ public FeatureStore createFeatureStore() { RedisDataStoreImpl core = new RedisDataStoreImpl(this); - return CachingStoreWrapper.builder(core).caching(this.caching).build(); + return CachingStoreWrapper.builder(core).caching(this.caching).cacheMonitor(this.cacheMonitor).build(); } } diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java new file mode 100644 index 000000000..1148a3566 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java @@ -0,0 +1,53 @@ +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.FeatureStoreFactory; +import com.launchdarkly.client.integrations.CacheMonitor; +import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.client.integrations.PersistentDataStoreBuilder.StaleValuesPolicy; + +import java.util.concurrent.TimeUnit; + +/** + * Interface for a factory that creates some implementation of a persistent data store. + *

    + * Note that currently this interface contains methods that are duplicates of the methods in + * {@link PersistentDataStoreBuilder}. This is necessary to preserve backward compatibility with the + * implementation of persistent data stores in earlier versions of the SDK. The new recommended usage + * is described in {@link com.launchdarkly.client.Components#persistentDataStore}, and starting in + * version 5.0 these redundant methods will be removed. + * + * @see com.launchdarkly.client.Components + * @since 4.11.0 + */ +public interface PersistentDataStoreFactory extends FeatureStoreFactory { + /** + * Called internally from {@link PersistentDataStoreBuilder}. + * + * @param cacheTime the cache TTL in whatever units you wish + * @param cacheTimeUnit the time unit + * @deprecated Calling this method directly on this component is deprecated. See {@link com.launchdarkly.client.Components#persistentDataStore} + * for the new usage. + */ + @Deprecated + void cacheTime(long cacheTime, TimeUnit cacheTimeUnit); + + /** + * Called internally from {@link PersistentDataStoreBuilder}. + * + * @param staleValuesPolicy a {@link StaleValuesPolicy} constant + * @deprecated Calling this method directly on this component is deprecated. See {@link com.launchdarkly.client.Components#persistentDataStore} + * for the new usage. + */ + @Deprecated + void staleValuesPolicy(StaleValuesPolicy staleValuesPolicy); + + /** + * Called internally from {@link PersistentDataStoreBuilder}. + * + * @param cacheMonitor an instance of {@link CacheMonitor} + * @deprecated Calling this method directly on this component is deprecated. See {@link com.launchdarkly.client.Components#persistentDataStore} + * for the new usage. + */ + @Deprecated + void cacheMonitor(CacheMonitor cacheMonitor); +} diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 47de79688..8e841a52e 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -13,10 +13,12 @@ import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.integrations.CacheMonitor; import java.io.IOException; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; @@ -32,6 +34,7 @@ * * @since 4.6.0 */ +@SuppressWarnings("deprecation") public class CachingStoreWrapper implements FeatureStore { private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; @@ -52,7 +55,7 @@ public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { return new Builder(core); } - protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching) { + protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching, CacheMonitor cacheMonitor) { this.core = core; this.caching = caching; @@ -81,40 +84,45 @@ public Boolean load(String key) throws Exception { } }; - if (caching.isInfiniteTtl()) { - itemCache = CacheBuilder.newBuilder().build(itemLoader); - allCache = CacheBuilder.newBuilder().build(allLoader); - executorService = null; - } else if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.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; - } else { - // 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). - + if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC) { ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat(CACHE_REFRESH_THREAD_POOL_NAME_FORMAT).setDaemon(true).build(); ExecutorService parentExecutor = Executors.newSingleThreadExecutor(threadFactory); executorService = MoreExecutors.listeningDecorator(parentExecutor); - - if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC) { - itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); - } - itemCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(itemLoader); - allCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(allLoader); + + // Note that the REFRESH_ASYNC mode is only used for itemCache, not allCache, since retrieving all flags is + // less frequently needed and we don't want to incur the extra overhead. + itemLoader = CacheLoader.asyncReloading(itemLoader, executorService); + } else { + executorService = null; } - - if (caching.isInfiniteTtl()) { - initCache = CacheBuilder.newBuilder().build(initLoader); + + itemCache = newCacheBuilder(caching, cacheMonitor).build(itemLoader); + allCache = newCacheBuilder(caching, cacheMonitor).build(allLoader); + initCache = newCacheBuilder(caching, cacheMonitor).build(initLoader); + + if (cacheMonitor != null) { + cacheMonitor.setSource(new CacheStatsSource()); + } + } + } + + private static CacheBuilder newCacheBuilder(FeatureStoreCacheConfig caching, CacheMonitor cacheMonitor) { + CacheBuilder builder = CacheBuilder.newBuilder(); + if (!caching.isInfiniteTtl()) { + if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.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. + builder = builder.expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()); } else { - initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); + // 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. + builder = builder.refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()); } } + if (cacheMonitor != null) { + builder = builder.recordStats(); + } + return builder; } @Override @@ -291,6 +299,23 @@ private ImmutableMap itemsOnlyIfNotDeleted( return builder.build(); } + private final class CacheStatsSource implements Callable { + public CacheMonitor.CacheStats call() { + if (itemCache == null || allCache == null) { + return null; + } + CacheStats itemStats = itemCache.stats(); + CacheStats allStats = allCache.stats(); + return new CacheMonitor.CacheStats( + itemStats.hitCount() + allStats.hitCount(), + itemStats.missCount() + allStats.missCount(), + itemStats.loadSuccessCount() + allStats.loadSuccessCount(), + itemStats.loadExceptionCount() + allStats.loadExceptionCount(), + itemStats.totalLoadTime() + allStats.totalLoadTime(), + itemStats.evictionCount() + allStats.evictionCount()); + } + } + private static class CacheKey { final VersionedDataKind kind; final String key; @@ -326,6 +351,7 @@ public int hashCode() { public static class Builder { private final FeatureStoreCore core; private FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + private CacheMonitor cacheMonitor = null; Builder(FeatureStoreCore core) { this.core = core; @@ -341,12 +367,22 @@ public Builder caching(FeatureStoreCacheConfig caching) { return this; } + /** + * Sets the cache monitor instance. + * @param cacheMonitor an instance of {@link CacheMonitor} + * @return the builder + */ + public Builder cacheMonitor(CacheMonitor cacheMonitor) { + this.cacheMonitor = cacheMonitor; + return this; + } + /** * Creates and configures the wrapper object. * @return a {@link CachingStoreWrapper} instance */ public CachingStoreWrapper build() { - return new CachingStoreWrapper(core, caching); + return new CachingStoreWrapper(core, caching, cacheMonitor); } } } diff --git a/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java index 08febe5ad..058b252d0 100644 --- a/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java @@ -1,9 +1,17 @@ package com.launchdarkly.client; +import com.google.common.cache.CacheStats; + import org.junit.BeforeClass; +import org.junit.Test; import java.net.URI; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.Assume.assumeThat; import static org.junit.Assume.assumeTrue; import redis.clients.jedis.Jedis; @@ -41,4 +49,32 @@ protected void clearAllData() { client.flushDB(); } } + + @Test + public void canGetCacheStats() { + assumeThat(cached, is(true)); + + CacheStats stats = store.getCacheStats(); + + assertThat(stats, equalTo(new CacheStats(0, 0, 0, 0, 0, 0))); + + // Cause a cache miss + store.get(FEATURES, "key1"); + stats = store.getCacheStats(); + assertThat(stats.hitCount(), equalTo(0L)); + assertThat(stats.missCount(), equalTo(1L)); + assertThat(stats.loadSuccessCount(), equalTo(1L)); // even though it's a miss, it's a "success" because there was no exception + assertThat(stats.loadExceptionCount(), equalTo(0L)); + + // Cause a cache hit + store.upsert(FEATURES, new FeatureFlagBuilder("key2").version(1).build()); // inserting the item also caches it + store.get(FEATURES, "key2"); // now it's a cache hit + stats = store.getCacheStats(); + assertThat(stats.hitCount(), equalTo(1L)); + assertThat(stats.missCount(), equalTo(1L)); + assertThat(stats.loadSuccessCount(), equalTo(1L)); + assertThat(stats.loadExceptionCount(), equalTo(0L)); + + // We have no way to force a load exception with a real Redis store + } } diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index 6419b6db1..db8e5ceba 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -4,7 +4,9 @@ import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.integrations.CacheMonitor; +import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -21,9 +23,10 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.fail; import static org.junit.Assume.assumeThat; -@SuppressWarnings("javadoc") +@SuppressWarnings({ "javadoc", "deprecation" }) @RunWith(Parameterized.class) public class CachingStoreWrapperTest { @@ -62,7 +65,7 @@ public static Iterable data() { public CachingStoreWrapperTest(CachingMode cachingMode) { this.cachingMode = cachingMode; this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig()); + this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig(), null); } @Test @@ -388,7 +391,7 @@ public void initializedCanCacheFalseResult() throws Exception { assumeThat(cachingMode.isCached(), 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, FeatureStoreCacheConfig.enabled().ttlMillis(500))) { + try (CachingStoreWrapper wrapper1 = new CachingStoreWrapper(core, FeatureStoreCacheConfig.enabled().ttlMillis(500), null)) { assertThat(wrapper1.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -406,6 +409,51 @@ public void initializedCanCacheFalseResult() throws Exception { } } + @Test + public void canGetCacheStats() throws Exception { + assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); + + CacheMonitor cacheMonitor = new CacheMonitor(); + + try (CachingStoreWrapper w = new CachingStoreWrapper(core, FeatureStoreCacheConfig.enabled().ttlSeconds(30), cacheMonitor)) { + CacheMonitor.CacheStats stats = cacheMonitor.getCacheStats(); + + assertThat(stats, equalTo(new CacheMonitor.CacheStats(0, 0, 0, 0, 0, 0))); + + // Cause a cache miss + w.get(THINGS, "key1"); + stats = cacheMonitor.getCacheStats(); + assertThat(stats.getHitCount(), equalTo(0L)); + assertThat(stats.getMissCount(), equalTo(1L)); + assertThat(stats.getLoadSuccessCount(), equalTo(1L)); // even though it's a miss, it's a "success" because there was no exception + assertThat(stats.getLoadExceptionCount(), equalTo(0L)); + + // Cause a cache hit + core.forceSet(THINGS, new MockItem("key2", 1, false)); + w.get(THINGS, "key2"); // this one is a cache miss, but causes the item to be loaded and cached + w.get(THINGS, "key2"); // now it's a cache hit + stats = cacheMonitor.getCacheStats(); + assertThat(stats.getHitCount(), equalTo(1L)); + assertThat(stats.getMissCount(), equalTo(2L)); + assertThat(stats.getLoadSuccessCount(), equalTo(2L)); + assertThat(stats.getLoadExceptionCount(), equalTo(0L)); + + // Cause a load exception + core.fakeError = new RuntimeException("sorry"); + try { + w.get(THINGS, "key3"); // cache miss -> tries to load the item -> gets an exception + fail("expected exception"); + } catch (RuntimeException e) { + assertThat(e.getCause(), is((Throwable)core.fakeError)); + } + stats = cacheMonitor.getCacheStats(); + assertThat(stats.getHitCount(), equalTo(1L)); + assertThat(stats.getMissCount(), equalTo(3L)); + assertThat(stats.getLoadSuccessCount(), equalTo(2L)); + assertThat(stats.getLoadExceptionCount(), equalTo(1L)); + } + } + private Map, Map> makeData(MockItem... items) { Map innerMap = new HashMap<>(); for (MockItem item: items) { From ce4a764a2e899ccbdb785e245e5587c40f8f4ac6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 17:46:16 -0800 Subject: [PATCH 230/327] misc fixes --- .../com/launchdarkly/client/Components.java | 20 +++++---- .../com/launchdarkly/client/LDConfig.java | 6 +-- .../client/RedisFeatureStore.java | 3 ++ .../client/integrations/CacheMonitor.java | 6 ++- .../PersistentDataStoreBuilder.java | 41 ++++++++----------- .../client/integrations/Redis.java | 14 ++++++- .../client/FeatureStoreCachingTest.java | 2 +- .../client/LDClientEvaluationTest.java | 10 ++--- .../client/LDClientEventTest.java | 4 +- .../client/LDClientLddModeTest.java | 1 + .../client/LDClientOfflineTest.java | 1 + .../com/launchdarkly/client/LDClientTest.java | 2 +- .../RedisFeatureStoreBuilderTest.java | 2 +- .../integrations/RedisFeatureStoreTest.java | 15 +++++-- .../client/utils/CachingStoreWrapperTest.java | 1 - 15 files changed, 75 insertions(+), 53 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index fbc03a0fb..7049deb9e 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -21,13 +21,13 @@ public abstract class Components { /** * Returns a factory for the default in-memory implementation of a data store. - * + *

    * Note that the interface is still named {@link FeatureStoreFactory}, but in a future version it * will be renamed to {@code DataStoreFactory}. * * @return a factory object - * @since 4.11.0 * @see LDConfig.Builder#dataStore(FeatureStoreFactory) + * @since 4.11.0 */ public static FeatureStoreFactory inMemoryDataStore() { return inMemoryFeatureStoreFactory; @@ -39,15 +39,14 @@ public static FeatureStoreFactory inMemoryDataStore() { * This method is used in conjunction with another factory object provided by specific components * such as the Redis integration. The latter provides builder methods for options that are specific * to that integration, while the {@link PersistentDataStoreBuilder} provides options like - * that are - * applicable to any persistent data store (such as caching). For example: + * that are applicable to any persistent data store (such as caching). For example: * *

    
        *     LDConfig config = new LDConfig.Builder()
        *         .dataStore(
        *             Components.persistentDataStore(
        *                 Redis.dataStore().url("redis://my-redis-host")
    -   *             ).ttlSeconds(15)
    +   *             ).cacheSeconds(15)
        *         )
        *         .build();
        * 
    @@ -56,6 +55,9 @@ public static FeatureStoreFactory inMemoryDataStore() { * * @param storeFactory the factory/builder for the specific kind of persistent data store * @return a {@link PersistentDataStoreBuilder} + * @see LDConfig.Builder#dataStore(FeatureStoreFactory) + * @see com.launchdarkly.client.integrations.Redis + * @since 4.11.0 */ public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { return new PersistentDataStoreBuilder(storeFactory); @@ -74,7 +76,8 @@ public static FeatureStoreFactory inMemoryFeatureStore() { /** * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. * @return a factory/builder object - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()}. + * @deprecated Use {@link #persistentDataStore(PersistentDataStoreFactory)} with + * {@link com.launchdarkly.client.integrations.Redis#dataStore()}. */ @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore() { @@ -85,8 +88,9 @@ public static RedisFeatureStoreBuilder redisFeatureStore() { * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. * @param redisUri the URI of the Redis host * @return a factory/builder object - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} and - * {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder#uri(URI)}. + * @deprecated Use {@link #persistentDataStore(PersistentDataStoreFactory)} with + * {@link com.launchdarkly.client.integrations.Redis#dataStore()} and + * {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder#uri(URI)}. */ @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 00db0bbe0..bc73cfb14 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -210,9 +210,9 @@ public Builder streamURI(URI streamURI) { /** * Sets the implementation of the data store to be used for holding feature flags and * related data received from LaunchDarkly, using a factory object. The default is - * {@link Components#inMemoryDataStore()}, but you may use {@link Components#redisFeatureStore()} - * or a custom implementation. - * + * {@link Components#inMemoryDataStore()}; for database integrations, use + * {@link Components#persistentDataStore(com.launchdarkly.client.interfaces.PersistentDataStoreFactory)}. + *

    * Note that the interface is still called {@link FeatureStoreFactory}, but in a future version * it will be renamed to {@code DataStoreFactory}. * diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 9713704a7..955cb2bab 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -57,6 +57,9 @@ public void close() throws IOException { /** * Return the underlying Guava cache stats object. + *

    + * In the newer data store API, there is a different way to do this. See + * {@link com.launchdarkly.client.integrations.PersistentDataStoreBuilder#cacheMonitor(com.launchdarkly.client.integrations.CacheMonitor)}. * * @return the cache statistics object. */ diff --git a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java index 67e8e7d4b..b1e8bd6e6 100644 --- a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java +++ b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java @@ -5,8 +5,8 @@ /** * A conduit that an application can use to monitor caching behavior of a persistent data store. - *

    - * See {@link PersistentDataStoreBuilder#cacheMonitor(CacheMonitor)} + * + * @see PersistentDataStoreBuilder#cacheMonitor(CacheMonitor) * @since 4.11.0 */ public final class CacheMonitor { @@ -44,6 +44,8 @@ public CacheStats getCacheStats() { * This is based on the data provided by Guava's caching framework. The SDK currently uses Guava * internally, but is not guaranteed to always do so, and to avoid embedding Guava API details in * the SDK API this is provided as a separate class. + * + * @since 4.11.0 */ public static final class CacheStats { private final long hitCount; diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java index 6f69ac4a5..d2462228e 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java @@ -1,6 +1,5 @@ package com.launchdarkly.client.integrations; -import com.launchdarkly.client.Components; import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.FeatureStoreFactory; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; @@ -15,27 +14,21 @@ * There is also universal behavior that the SDK provides for all persistent data stores, such as caching; * the {@link PersistentDataStoreBuilder} adds this. *

    - * An example of using - *

    - * options that are specific to that implementation; the builder implements {@link PersistentDataStoreFactory}. - * Then call {@link Components#persistentDataStore(PersistentDataStoreFactory)} to wrap that object in the - * standard SDK persistent data store behavior, in the form of a {@link PersistentDataStoreBuilder} - * which can be configured with caching options. Finally, pass this to - * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(FeatureStoreFactory)}. - * For example, using the Redis integration: - * - *

    
    -   *     LDConfig config = new LDConfig.Builder()
    -   *         .dataStore(
    -   *             Components.persistentDataStore(
    -   *                 Redis.dataStore().url("redis://my-redis-host")
    -   *             ).cacheSeconds(15)
    -   *         )
    -   *         .build();
    -   * 
    - * - * In this example, {@code .url()} is an option specifically for the Redis integration, whereas - * {@code ttlSeconds()} is an option that can be used for any persistent data store. + * After configuring this object, pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataStore(FeatureStoreFactory)} + * to use it in the SDK configuration. For example, using the Redis integration: + * + *
    
    + *     LDConfig config = new LDConfig.Builder()
    + *         .dataStore(
    + *             Components.persistentDataStore(
    + *                 Redis.dataStore().url("redis://my-redis-host")
    + *             ).cacheSeconds(15)
    + *         )
    + *         .build();
    + * 
    + * + * In this example, {@code .url()} is an option specifically for the Redis integration, whereas + * {@code ttlSeconds()} is an option that can be used for any persistent data store. * * @since 4.11.0 */ @@ -140,7 +133,7 @@ public PersistentDataStoreBuilder cacheTime(long cacheTime, TimeUnit cacheTimeUn * @param millis the cache TTL in milliseconds * @return the builder */ - public PersistentDataStoreBuilder cacheTtlMillis(long millis) { + public PersistentDataStoreBuilder cacheMillis(long millis) { return cacheTime(millis, TimeUnit.MILLISECONDS); } @@ -150,7 +143,7 @@ public PersistentDataStoreBuilder cacheTtlMillis(long millis) { * @param seconds the cache TTL in seconds * @return the builder */ - public PersistentDataStoreBuilder cacheTtlSeconds(long seconds) { + public PersistentDataStoreBuilder cacheSeconds(long seconds) { return cacheTime(seconds, TimeUnit.SECONDS); } diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java index 050b581bb..7a167ae9d 100644 --- a/src/main/java/com/launchdarkly/client/integrations/Redis.java +++ b/src/main/java/com/launchdarkly/client/integrations/Redis.java @@ -10,8 +10,20 @@ public abstract class Redis { * Returns a builder object for creating a Redis-backed data store. *

    * This object can be modified with {@link RedisDataStoreBuilder} methods for any desired - * custom settings, before including it in the SDK configuration with + * custom Redis options. Then, pass it to {@link com.launchdarkly.client.Components#persistentDataStore(com.launchdarkly.client.interfaces.PersistentDataStoreFactory)} + * and set any desired caching options. Finally, pass the result to * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. + * For example: + * + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .dataStore(
    +   *             Components.persistentDataStore(
    +   *                 Redis.dataStore().url("redis://my-redis-host")
    +   *             ).cacheSeconds(15)
    +   *         )
    +   *         .build();
    +   * 
    * * @return a data store configuration object */ diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java index c9259b622..2d90cd4a6 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java @@ -10,7 +10,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -@SuppressWarnings("javadoc") +@SuppressWarnings({ "deprecation", "javadoc" }) public class FeatureStoreCachingTest { @Test public void disabledHasExpectedProperties() { diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index ff41ad1c1..5ef7397d0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -35,8 +35,8 @@ public class LDClientEvaluationTest { private LDConfig config = new LDConfig.Builder() .dataStore(specificFeatureStore(featureStore)) - .eventProcessorFactory(Components.nullEventProcessor()) - .dataSource(Components.nullUpdateProcessor()) + .eventProcessor(Components.nullEventProcessor()) + .dataSource(Components.nullDataSource()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); @@ -223,7 +223,7 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { FeatureStore badFeatureStore = new InMemoryFeatureStore(); LDConfig badConfig = new LDConfig.Builder() .dataStore(specificFeatureStore(badFeatureStore)) - .eventProcessorFactory(Components.nullEventProcessor()) + .eventProcessor(Components.nullEventProcessor()) .dataSource(specificUpdateProcessor(failedUpdateProcessor())) .startWaitMillis(0) .build(); @@ -264,8 +264,8 @@ public void appropriateErrorForUnexpectedException() throws Exception { FeatureStore badFeatureStore = featureStoreThatThrowsException(new RuntimeException("sorry")); LDConfig badConfig = new LDConfig.Builder() .dataStore(specificFeatureStore(badFeatureStore)) - .eventProcessorFactory(Components.nullEventProcessor()) - .dataSource(Components.nullUpdateProcessor()) + .eventProcessor(Components.nullEventProcessor()) + .dataSource(Components.nullDataSource()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 941c2615d..caf90b6fe 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -30,8 +30,8 @@ public class LDClientEventTest { private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() .dataStore(specificFeatureStore(featureStore)) - .eventProcessorFactory(specificEventProcessor(eventSink)) - .dataSource(Components.nullUpdateProcessor()) + .eventProcessor(specificEventProcessor(eventSink)) + .dataSource(Components.nullDataSource()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java index afc4ca6c5..21a142823 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -15,6 +15,7 @@ @SuppressWarnings("javadoc") public class LDClientLddModeTest { + @SuppressWarnings("deprecation") @Test public void lddModeClientHasNullUpdateProcessor() throws IOException { LDConfig config = new LDConfig.Builder() diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 45aa48bfa..2785fc5d1 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -21,6 +21,7 @@ public class LDClientOfflineTest { private static final LDUser user = new LDUser("user"); + @SuppressWarnings("deprecation") @Test public void offlineClientHasNullUpdateProcessor() throws IOException { LDConfig config = new LDConfig.Builder() diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 02dc39657..ae4a20bd1 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -341,7 +341,7 @@ private void expectEventsSent(int count) { private LDClientInterface createMockClient(LDConfig.Builder config) { config.dataSource(TestUtil.specificUpdateProcessor(updateProcessor)); - config.eventProcessorFactory(TestUtil.specificEventProcessor(eventProcessor)); + config.eventProcessor(TestUtil.specificEventProcessor(eventProcessor)); return new LDClient("SDK_KEY", config.build()); } diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java index de8cf2570..b537d68ad 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java @@ -13,7 +13,7 @@ import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; -@SuppressWarnings("javadoc") +@SuppressWarnings({ "deprecation", "javadoc" }) public class RedisFeatureStoreBuilderTest { @Test public void testDefaultValues() { diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java index 8e4f6ea1b..889ec344d 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java @@ -1,7 +1,7 @@ package com.launchdarkly.client.integrations; +import com.launchdarkly.client.Components; import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.FeatureStoreDatabaseTestBase; import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; import com.launchdarkly.client.utils.CachingStoreWrapper; @@ -31,14 +31,21 @@ public static void maybeSkipDatabaseTests() { @Override protected FeatureStore makeStore() { - RedisDataStoreBuilder builder = Redis.dataStore().uri(REDIS_URI); - builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); + RedisDataStoreBuilder redisBuilder = Redis.dataStore().uri(REDIS_URI); + PersistentDataStoreBuilder builder = Components.persistentDataStore(redisBuilder); + if (cached) { + builder.cacheSeconds(30); + } else { + builder.noCaching(); + } return builder.createFeatureStore(); } @Override protected FeatureStore makeStoreWithPrefix(String prefix) { - return Redis.dataStore().uri(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).createFeatureStore(); + return Components.persistentDataStore( + Redis.dataStore().uri(REDIS_URI).prefix(prefix) + ).noCaching().createFeatureStore(); } @Override diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index db8e5ceba..306501bd9 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -6,7 +6,6 @@ import com.launchdarkly.client.VersionedDataKind; import com.launchdarkly.client.integrations.CacheMonitor; -import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; From 16201e94346fb2fdaef65891f046c50b1b7545b0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 21:09:40 -0800 Subject: [PATCH 231/327] remove some unnecessary 4.x-style methods from new types --- .../client/FeatureStoreCacheConfig.java | 51 ++++++++++++------- .../client/RedisFeatureStore.java | 2 +- .../client/RedisFeatureStoreBuilder.java | 29 +++++------ .../PersistentDataStoreBuilder.java | 23 ++++++--- .../integrations/RedisDataStoreBuilder.java | 50 ++---------------- .../PersistentDataStoreFactory.java | 40 +++------------ .../RedisFeatureStoreBuilderTest.java | 5 +- 7 files changed, 77 insertions(+), 123 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 65451b712..1860e7b3e 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -91,7 +91,40 @@ public enum StaleValuesPolicy { * See: CacheBuilder for * more specific information on cache semantics. */ - REFRESH_ASYNC + REFRESH_ASYNC; + + /** + * Used internally for backward compatibility. + * @return the equivalent enum value + * @since 4.11.0 + */ + public PersistentDataStoreBuilder.StaleValuesPolicy toNewEnum() { + switch (this) { + case REFRESH: + return PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH; + case REFRESH_ASYNC: + return PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH_ASYNC; + default: + return PersistentDataStoreBuilder.StaleValuesPolicy.EVICT; + } + } + + /** + * Used internally for backward compatibility. + * @param policy the enum value in the new API + * @return the equivalent enum value + * @since 4.11.0 + */ + public static StaleValuesPolicy fromNewEnum(PersistentDataStoreBuilder.StaleValuesPolicy policy) { + switch (policy) { + case REFRESH: + return StaleValuesPolicy.REFRESH; + case REFRESH_ASYNC: + return StaleValuesPolicy.REFRESH_ASYNC; + default: + return StaleValuesPolicy.EVICT; + } + } }; /** @@ -239,22 +272,6 @@ public FeatureStoreCacheConfig staleValuesPolicy(StaleValuesPolicy policy) { return new FeatureStoreCacheConfig(cacheTime, cacheTimeUnit, policy); } - /** - * Used internally for backward compatibility from the newer builder API. - * - * @param policy a {@link com.launchdarkly.client.integrations.PersistentDataStoreBuilder.StaleValuesPolicy} constant - * @return an updated parameters object - */ - public FeatureStoreCacheConfig staleValuesPolicy(PersistentDataStoreBuilder.StaleValuesPolicy policy) { - switch (policy) { - case REFRESH: - return staleValuesPolicy(StaleValuesPolicy.REFRESH); - case REFRESH_ASYNC: - return staleValuesPolicy(StaleValuesPolicy.REFRESH_ASYNC); - default: - return staleValuesPolicy(StaleValuesPolicy.EVICT); - } - } @Override public boolean equals(Object other) { if (other instanceof FeatureStoreCacheConfig) { diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 955cb2bab..ebd36913c 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -75,7 +75,7 @@ public CacheStats getCacheStats() { * @param builder the configured builder to construct the store with. */ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - wrappedStore = builder.wrappedBuilder.createFeatureStore(); + wrappedStore = builder.wrappedOuterBuilder.createFeatureStore(); } /** diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 07e8684aa..c447da57c 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.launchdarkly.client.integrations.CacheMonitor; +import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; import com.launchdarkly.client.integrations.Redis; import com.launchdarkly.client.integrations.RedisDataStoreBuilder; @@ -39,22 +40,23 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { */ public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS; + final PersistentDataStoreBuilder wrappedOuterBuilder; final RedisDataStoreBuilder wrappedBuilder; // We have to keep track of these caching parameters separately in order to support some deprecated setters - FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; boolean refreshStaleValues = false; boolean asyncRefresh = false; // These constructors are called only from Components RedisFeatureStoreBuilder() { wrappedBuilder = Redis.dataStore(); + wrappedOuterBuilder = Components.persistentDataStore(wrappedBuilder); // In order to make the cacheStats() method on the deprecated RedisFeatureStore class work, we need to // turn on cache monitoring. In the newer API, cache monitoring would only be turned on if the application // specified its own CacheMonitor, but in the deprecated API there's no way to know if they will want the // statistics or not. - wrappedBuilder.cacheMonitor(new CacheMonitor()); + wrappedOuterBuilder.cacheMonitor(new CacheMonitor()); } RedisFeatureStoreBuilder(URI uri) { @@ -72,8 +74,7 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { this(); wrappedBuilder.uri(uri); - caching = caching.ttlSeconds(cacheTimeSecs); - wrappedBuilder.caching(caching); + wrappedOuterBuilder.cacheSeconds(cacheTimeSecs); } /** @@ -89,8 +90,7 @@ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { public RedisFeatureStoreBuilder(String scheme, String host, int port, long cacheTimeSecs) throws URISyntaxException { this(); wrappedBuilder.uri(new URI(scheme, null, host, port, null, null, null)); - caching = caching.ttlSeconds(cacheTimeSecs); - wrappedBuilder.caching(caching); + wrappedOuterBuilder.cacheSeconds(cacheTimeSecs); } /** @@ -153,8 +153,8 @@ public RedisFeatureStoreBuilder tls(boolean tls) { * @since 4.6.0 */ public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { - this.caching = caching; - wrappedBuilder.caching(caching); + wrappedOuterBuilder.cacheTime(caching.getCacheTime(), caching.getCacheTimeUnit()); + wrappedOuterBuilder.staleValuesPolicy(caching.getStaleValuesPolicy().toNewEnum()); return this; } @@ -194,13 +194,12 @@ 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 (refreshStaleValues) { - caching = caching.staleValuesPolicy(this.asyncRefresh ? - FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : - FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH); + wrappedOuterBuilder.staleValuesPolicy(this.asyncRefresh ? + PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH_ASYNC : + PersistentDataStoreBuilder.StaleValuesPolicy.REFRESH); } else { - caching = caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); + wrappedOuterBuilder.staleValuesPolicy(PersistentDataStoreBuilder.StaleValuesPolicy.EVICT); } - wrappedBuilder.caching(caching); } /** @@ -225,9 +224,7 @@ public RedisFeatureStoreBuilder prefix(String prefix) { * @deprecated use {@link #caching(FeatureStoreCacheConfig)} and {@link FeatureStoreCacheConfig#ttl(long, TimeUnit)}. */ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - caching = caching.ttl(cacheTime, timeUnit) - .staleValuesPolicy(this.caching.getStaleValuesPolicy()); - wrappedBuilder.caching(caching); + wrappedOuterBuilder.cacheTime(cacheTime, timeUnit); return this; } diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java index d2462228e..fd6884a12 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java @@ -1,8 +1,11 @@ package com.launchdarkly.client.integrations; import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.FeatureStoreFactory; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; +import com.launchdarkly.client.utils.CachingStoreWrapper; +import com.launchdarkly.client.utils.FeatureStoreCore; import java.util.concurrent.TimeUnit; @@ -32,6 +35,7 @@ * * @since 4.11.0 */ +@SuppressWarnings("deprecation") public final class PersistentDataStoreBuilder implements FeatureStoreFactory { /** * The default value for the cache TTL. @@ -39,7 +43,9 @@ public final class PersistentDataStoreBuilder implements FeatureStoreFactory { public static final int DEFAULT_CACHE_TTL_SECONDS = 15; private final PersistentDataStoreFactory persistentDataStoreFactory; - + FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + CacheMonitor cacheMonitor = null; + /** * Possible values for {@link #staleValuesPolicy(StaleValuesPolicy)}. */ @@ -96,7 +102,11 @@ public PersistentDataStoreBuilder(PersistentDataStoreFactory persistentDataStore @Override public FeatureStore createFeatureStore() { - return persistentDataStoreFactory.createFeatureStore(); + FeatureStoreCore core = persistentDataStoreFactory.createPersistentDataStore(); + return CachingStoreWrapper.builder(core) + .caching(caching) + .cacheMonitor(cacheMonitor) + .build(); } /** @@ -121,9 +131,8 @@ public PersistentDataStoreBuilder noCaching() { * @param cacheTimeUnit the time unit * @return the builder */ - @SuppressWarnings("deprecation") public PersistentDataStoreBuilder cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { - persistentDataStoreFactory.cacheTime(cacheTime, cacheTimeUnit); + caching = caching.ttl(cacheTime, cacheTimeUnit); return this; } @@ -170,9 +179,8 @@ public PersistentDataStoreBuilder cacheForever() { * @param staleValuesPolicy a {@link StaleValuesPolicy} constant * @return the builder */ - @SuppressWarnings("deprecation") public PersistentDataStoreBuilder staleValuesPolicy(StaleValuesPolicy staleValuesPolicy) { - persistentDataStoreFactory.staleValuesPolicy(staleValuesPolicy); + caching = caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.fromNewEnum(staleValuesPolicy)); return this; } @@ -201,9 +209,8 @@ public PersistentDataStoreBuilder staleValuesPolicy(StaleValuesPolicy staleValue * @param cacheMonitor an instance of {@link CacheMonitor} * @return the builder */ - @SuppressWarnings("deprecation") public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { - persistentDataStoreFactory.cacheMonitor(cacheMonitor); + this.cacheMonitor = cacheMonitor; return this; } } diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index 605bc4f60..70b25b792 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -1,10 +1,7 @@ package com.launchdarkly.client.integrations; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder.StaleValuesPolicy; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; -import com.launchdarkly.client.utils.CachingStoreWrapper; +import com.launchdarkly.client.utils.FeatureStoreCore; import java.net.URI; import java.util.concurrent.TimeUnit; @@ -20,7 +17,7 @@ * Obtain an instance of this class by calling {@link Redis#dataStore()}. After calling its methods * to specify any desired custom settings, you can pass it directly into the SDK configuration with * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. - * You do not need to call {@link #createFeatureStore()} yourself to build the actual data store; that + * You do not need to call {@link #createPersistentDataStore()} yourself to build the actual data store; that * will be done by the SDK. *

    * Builder calls can be chained, for example: @@ -37,7 +34,6 @@ * * @since 4.11.0 */ -@SuppressWarnings("deprecation") public final class RedisDataStoreBuilder implements PersistentDataStoreFactory { /** * The default value for the Redis URI: {@code redis://localhost:6379} @@ -56,8 +52,6 @@ public final class RedisDataStoreBuilder implements PersistentDataStoreFactory { Integer database = null; String password = null; boolean tls = false; - FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - CacheMonitor cacheMonitor = null; JedisPoolConfig poolConfig = null; // These constructors are called only from Implementations @@ -118,36 +112,6 @@ public RedisDataStoreBuilder uri(URI redisUri) { return this; } - /** - * Specifies whether local caching should be enabled and if so, sets the cache properties. Local - * caching is enabled by default; see {@link FeatureStoreCacheConfig#DEFAULT}. To disable it, pass - * {@link FeatureStoreCacheConfig#disabled()} to this method. - * - * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters - * @return the builder - * @deprecated This has been superseded by the {@link PersistentDataStoreBuilder} interface. - */ - @Deprecated - public RedisDataStoreBuilder caching(FeatureStoreCacheConfig caching) { - this.caching = caching; - return this; - } - - @Override - public void cacheTime(long cacheTime, TimeUnit cacheTimeUnit) { - this.caching = caching.ttl(cacheTime, cacheTimeUnit); - } - - @Override - public void staleValuesPolicy(StaleValuesPolicy staleValuesPolicy) { - this.caching = caching.staleValuesPolicy(staleValuesPolicy); - } - - @Override - public void cacheMonitor(CacheMonitor cacheMonitor) { - this.cacheMonitor = cacheMonitor; - } - /** * Optionally configures the namespace prefix for all keys stored in Redis. * @@ -196,12 +160,8 @@ public RedisDataStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) return this; } - /** - * Called internally by the SDK to create the actual data store instance. - * @return the data store configured by this builder - */ - public FeatureStore createFeatureStore() { - RedisDataStoreImpl core = new RedisDataStoreImpl(this); - return CachingStoreWrapper.builder(core).caching(this.caching).cacheMonitor(this.cacheMonitor).build(); + @Override + public FeatureStoreCore createPersistentDataStore() { + return new RedisDataStoreImpl(this); } } diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java index 1148a3566..030dedd12 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java @@ -1,11 +1,7 @@ package com.launchdarkly.client.interfaces; -import com.launchdarkly.client.FeatureStoreFactory; -import com.launchdarkly.client.integrations.CacheMonitor; import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; -import com.launchdarkly.client.integrations.PersistentDataStoreBuilder.StaleValuesPolicy; - -import java.util.concurrent.TimeUnit; +import com.launchdarkly.client.utils.FeatureStoreCore; /** * Interface for a factory that creates some implementation of a persistent data store. @@ -19,35 +15,15 @@ * @see com.launchdarkly.client.Components * @since 4.11.0 */ -public interface PersistentDataStoreFactory extends FeatureStoreFactory { - /** - * Called internally from {@link PersistentDataStoreBuilder}. - * - * @param cacheTime the cache TTL in whatever units you wish - * @param cacheTimeUnit the time unit - * @deprecated Calling this method directly on this component is deprecated. See {@link com.launchdarkly.client.Components#persistentDataStore} - * for the new usage. - */ - @Deprecated - void cacheTime(long cacheTime, TimeUnit cacheTimeUnit); - - /** - * Called internally from {@link PersistentDataStoreBuilder}. - * - * @param staleValuesPolicy a {@link StaleValuesPolicy} constant - * @deprecated Calling this method directly on this component is deprecated. See {@link com.launchdarkly.client.Components#persistentDataStore} - * for the new usage. - */ - @Deprecated - void staleValuesPolicy(StaleValuesPolicy staleValuesPolicy); - +public interface PersistentDataStoreFactory { /** - * Called internally from {@link PersistentDataStoreBuilder}. + * Called internally from {@link PersistentDataStoreBuilder} to create the implementation object + * for the specific type of data store. * - * @param cacheMonitor an instance of {@link CacheMonitor} - * @deprecated Calling this method directly on this component is deprecated. See {@link com.launchdarkly.client.Components#persistentDataStore} - * for the new usage. + * @return the implementation object + * @deprecated Do not reference this method directly, as the {@link FeatureStoreCore} interface + * will be replaced in 5.0. */ @Deprecated - void cacheMonitor(CacheMonitor cacheMonitor); + FeatureStoreCore createPersistentDataStore(); } diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java index b537d68ad..3da39b69b 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java @@ -1,7 +1,5 @@ package com.launchdarkly.client.integrations; -import com.launchdarkly.client.FeatureStoreCacheConfig; - import org.junit.Test; import java.net.URISyntaxException; @@ -13,13 +11,12 @@ import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; -@SuppressWarnings({ "deprecation", "javadoc" }) +@SuppressWarnings("javadoc") public class RedisFeatureStoreBuilderTest { @Test public void testDefaultValues() { RedisDataStoreBuilder conf = Redis.dataStore(); assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); From d00065d1edc768ff64bdac2bcda28eac64003e1e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 21:09:47 -0800 Subject: [PATCH 232/327] add package info --- .../com/launchdarkly/client/interfaces/package-info.java | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/main/java/com/launchdarkly/client/interfaces/package-info.java diff --git a/src/main/java/com/launchdarkly/client/interfaces/package-info.java b/src/main/java/com/launchdarkly/client/interfaces/package-info.java new file mode 100644 index 000000000..d798dc8f0 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/package-info.java @@ -0,0 +1,7 @@ +/** + * The package for interfaces that allow customization of LaunchDarkly components. + *

    + * You will not need to refer to these types in your code unless you are creating a + * plug-in component, such as a database integration. + */ +package com.launchdarkly.client.interfaces; From f2d54daf0e9d26958953e9d3d299128250f24bce Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 21:10:11 -0800 Subject: [PATCH 233/327] deprecation --- .../com/launchdarkly/client/utils/CachingStoreWrapper.java | 5 ++++- 1 file changed, 4 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 8e841a52e..a4c7853ce 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -33,8 +33,11 @@ * Construct instances of this class with {@link CachingStoreWrapper#builder(FeatureStoreCore)}. * * @since 4.6.0 + * @deprecated Referencing this class directly is deprecated; it is now part of the implementation + * of {@link com.launchdarkly.client.integrations.PersistentDataStoreBuilder} and will be made + * package-private starting in version 5.0. */ -@SuppressWarnings("deprecation") +@Deprecated public class CachingStoreWrapper implements FeatureStore { private static final String CACHE_REFRESH_THREAD_POOL_NAME_FORMAT = "CachingStoreWrapper-refresher-pool-%d"; From bf75d5c4d7664cc4c80d31da29f08d5451fbe6b0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 21:11:15 -0800 Subject: [PATCH 234/327] misc cleanup --- .../launchdarkly/client/integrations/RedisFeatureStoreTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java index 889ec344d..b93718a2b 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java @@ -14,7 +14,7 @@ import redis.clients.jedis.Jedis; -@SuppressWarnings("javadoc") +@SuppressWarnings({ "deprecation", "javadoc" }) public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { private static final URI REDIS_URI = URI.create("redis://localhost:6379"); From 81c937f5173a933bca1dd2edeeb715e6ab025cb1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 13 Jan 2020 21:42:25 -0800 Subject: [PATCH 235/327] misc cleanup --- .../client/integrations/CacheMonitor.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java index b1e8bd6e6..618edc63d 100644 --- a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java +++ b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java @@ -20,7 +20,10 @@ public CacheMonitor() {} /** * Called internally by the SDK to establish a source for the statistics. * @param source provided by an internal SDK component + * @deprecated Referencing this method directly is deprecated. In a future version, it will + * only be visible to SDK implementation code. */ + @Deprecated public void setSource(Callable source) { this.source = source; } @@ -58,12 +61,12 @@ public static final class CacheStats { /** * Constructs a new instance. * - * @param hitCount - * @param missCount - * @param loadSuccessCount - * @param loadExceptionCount - * @param totalLoadTime - * @param evictionCount + * @param hitCount number of queries that produced a cache hit + * @param missCount number of queries that produced a cache miss + * @param loadSuccessCount number of cache misses that loaded a value without an exception + * @param loadExceptionCount number of cache misses that tried to load a value but got an exception + * @param totalLoadTime number of nanoseconds spent loading new values + * @param evictionCount number of cache entries that have been evicted */ public CacheStats(long hitCount, long missCount, long loadSuccessCount, long loadExceptionCount, long totalLoadTime, long evictionCount) { From 30637316732e9bdafb20353ba3a74b02f44dff03 Mon Sep 17 00:00:00 2001 From: Gavin Whelan Date: Tue, 14 Jan 2020 23:35:50 +0000 Subject: [PATCH 236/327] Add X-LaunchDarkly-Payload-ID header on event post. (#169) --- .../client/DefaultEventProcessor.java | 6 ++- .../client/DefaultEventProcessorTest.java | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 5aee5efa0..56fbd1b16 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -15,6 +15,7 @@ import java.util.Date; import java.util.List; import java.util.Random; +import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.Executors; @@ -42,6 +43,7 @@ final class DefaultEventProcessor implements EventProcessor { private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; private static final String EVENT_SCHEMA_VERSION = "3"; + private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; @@ -542,7 +544,8 @@ void stop() { private void postEvents(String json, int outputEventCount) { String uriStr = config.eventsURI.toString() + "/bulk"; - + String eventPayloadId = UUID.randomUUID().toString(); + logger.debug("Posting {} event(s) to {} with payload: {}", outputEventCount, uriStr, json); @@ -558,6 +561,7 @@ private void postEvents(String json, int outputEventCount) { .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json)) .addHeader("Content-Type", "application/json") .addHeader(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION) + .addHeader(EVENT_PAYLOAD_ID_HEADER, eventPayloadId) .build(); long startTime = System.currentTimeMillis(); diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index ce05c62fe..05e117819 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -11,6 +11,7 @@ import java.text.SimpleDateFormat; import java.util.Date; +import java.util.UUID; import java.util.concurrent.TimeUnit; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; @@ -22,6 +23,7 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assert.assertEquals; @@ -485,6 +487,50 @@ public void eventSchemaIsSent() throws Exception { } } + @Test + public void eventPayloadIdIsSent() throws Exception { + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + } + + RecordedRequest req = server.takeRequest(); + String payloadHeaderValue = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(payloadHeaderValue, notNullValue(String.class)); + assertThat(UUID.fromString(payloadHeaderValue), notNullValue(UUID.class)); + } + } + + @Test + public void eventPayloadIdReusedOnRetry() throws Exception { + MockResponse errorResponse = new MockResponse().setResponseCode(429); + Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + + try (MockWebServer server = makeStartedServer(errorResponse, eventsSuccessResponse(), eventsSuccessResponse())) { + try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + ep.sendEvent(e); + ep.flush(); + // Necessary to ensure the retry occurs before the second request for test assertion ordering + ep.waitUntilInactive(); + ep.sendEvent(e); + } + + // Failed response request + RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); + String payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); + // Retry request has same payload ID as failed request + req = server.takeRequest(0, TimeUnit.SECONDS); + String retryId = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(retryId, equalTo(payloadId)); + // Second request has different payload ID from first request + req = server.takeRequest(0, TimeUnit.SECONDS); + payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(retryId, not(equalTo(payloadId))); + } + } + @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); From d5c85c54f8f1d7bd7ab01a037f16f8ffdc6990fa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 14 Jan 2020 19:55:18 -0800 Subject: [PATCH 237/327] fix comment --- .../client/interfaces/PersistentDataStoreFactory.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java index 030dedd12..8931247d3 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java @@ -6,11 +6,8 @@ /** * Interface for a factory that creates some implementation of a persistent data store. *

    - * Note that currently this interface contains methods that are duplicates of the methods in - * {@link PersistentDataStoreBuilder}. This is necessary to preserve backward compatibility with the - * implementation of persistent data stores in earlier versions of the SDK. The new recommended usage - * is described in {@link com.launchdarkly.client.Components#persistentDataStore}, and starting in - * version 5.0 these redundant methods will be removed. + * This interface is implemented by database integrations. Usage is described in + * {@link com.launchdarkly.client.Components#persistentDataStore}. * * @see com.launchdarkly.client.Components * @since 4.11.0 From 1cf368a93aa9bb7dd1eb6e7bbbea38dcd62e9cf5 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Jan 2020 10:43:20 -0800 Subject: [PATCH 238/327] comment fixes --- src/main/java/com/launchdarkly/client/Components.java | 4 ++-- .../client/integrations/PersistentDataStoreBuilder.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 7049deb9e..e673b8f20 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -38,8 +38,8 @@ public static FeatureStoreFactory inMemoryDataStore() { *

    * This method is used in conjunction with another factory object provided by specific components * such as the Redis integration. The latter provides builder methods for options that are specific - * to that integration, while the {@link PersistentDataStoreBuilder} provides options like - * that are applicable to any persistent data store (such as caching). For example: + * to that integration, while the {@link PersistentDataStoreBuilder} provides options that are + * applicable to any persistent data store (such as caching). For example: * *

    
        *     LDConfig config = new LDConfig.Builder()
    diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java
    index fd6884a12..43a1b42b3 100644
    --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java
    +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java
    @@ -159,7 +159,7 @@ public PersistentDataStoreBuilder cacheSeconds(long seconds) {
       /**
        * Specifies that the in-memory cache should never expire. In this mode, data will be written
        * to both the underlying persistent store and the cache, but will only ever be read from
    -   * persistent store if the SDK is restarted.
    +   * the persistent store if the SDK is restarted.
        * 

    * Use this mode with caution: it means that in a scenario where multiple processes are sharing * the database, and the current process loses connectivity to LaunchDarkly while other processes From ff734ad771707aba45657776a9e7c70d55959c85 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Jan 2020 17:27:30 -0800 Subject: [PATCH 239/327] avoid exception with semver operators and non-strings --- src/main/java/com/launchdarkly/client/OperandType.java | 3 +++ .../launchdarkly/client/OperatorParameterizedTest.java | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/OperandType.java b/src/main/java/com/launchdarkly/client/OperandType.java index 861d155d4..1892c8357 100644 --- a/src/main/java/com/launchdarkly/client/OperandType.java +++ b/src/main/java/com/launchdarkly/client/OperandType.java @@ -27,6 +27,9 @@ public Object getValueAsType(LDValue value) { case date: return Util.jsonPrimitiveToDateTime(value); case semVer: + if (!value.isString()) { + return null; + } try { return SemanticVersion.parse(value.stringValue(), true); } catch (SemanticVersion.InvalidVersionException e) { diff --git a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java b/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java index e4e8ca61a..95f9cec02 100644 --- a/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java +++ b/src/test/java/com/launchdarkly/client/OperatorParameterizedTest.java @@ -115,7 +115,13 @@ public static Iterable data() { { Operator.semVerGreaterThan, LDValue.of("2.0"), LDValue.of("2.0.1"), false }, { Operator.semVerGreaterThan, LDValue.of("2.0.0-rc.1"), LDValue.of("2.0.0-rc.0"), true }, { Operator.semVerLessThan, LDValue.of("2.0.1"), invalidVer, false }, - { Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false } + { Operator.semVerGreaterThan, LDValue.of("2.0.1"), invalidVer, false }, + { Operator.semVerEqual, LDValue.ofNull(), LDValue.of("2.0.0"), false }, + { Operator.semVerEqual, LDValue.of(1), LDValue.of("2.0.0"), false }, + { Operator.semVerEqual, LDValue.of(true), LDValue.of("2.0.0"), false }, + { Operator.semVerEqual, LDValue.of("2.0.0"), LDValue.ofNull(), false }, + { Operator.semVerEqual, LDValue.of("2.0.0"), LDValue.of(1), false }, + { Operator.semVerEqual, LDValue.of("2.0.0"), LDValue.of(true), false } }); } From 18b93f7d3799782ddc32d1940bf9c6dd9d24777d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Jan 2020 17:49:15 -0800 Subject: [PATCH 240/327] don't include exception in error reason JSON rep --- .../com/launchdarkly/client/EvaluationReason.java | 14 +++++++++++++- .../launchdarkly/client/EvaluationReasonTest.java | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index dd3c4a248..752744842 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -1,5 +1,14 @@ package com.launchdarkly.client; +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +import java.io.IOException; import java.util.Objects; import static com.google.common.base.Preconditions.checkNotNull; @@ -317,7 +326,10 @@ private Fallthrough() */ public static class Error extends EvaluationReason { private final ErrorKind errorKind; - private final Exception exception; + private transient final Exception exception; + // The exception field is transient because we don't want it to be included in the JSON representation that + // is used in analytics events; the LD event service wouldn't know what to do with it (and it would include + // a potentially large amount of stacktrace data). private Error(ErrorKind errorKind, Exception exception) { super(Kind.ERROR); diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java index 0723165b0..4745aaaca 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -54,10 +54,20 @@ public void testPrerequisiteFailedSerialization() { @Test public void testErrorSerialization() { - EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.EXCEPTION); + EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.FLAG_NOT_FOUND); + String json = "{\"kind\":\"ERROR\",\"errorKind\":\"FLAG_NOT_FOUND\"}"; + assertJsonEqual(json, gson.toJson(reason)); + assertEquals("ERROR(FLAG_NOT_FOUND)", reason.toString()); + } + + @Test + public void testErrorSerializationWithException() { + // We do *not* want the JSON representation to include the exception, because that is used in events, and + // the LD event service won't know what to do with that field (which will also contain a big stacktrace). + EvaluationReason reason = EvaluationReason.exception(new Exception("something happened")); String json = "{\"kind\":\"ERROR\",\"errorKind\":\"EXCEPTION\"}"; assertJsonEqual(json, gson.toJson(reason)); - assertEquals("ERROR(EXCEPTION)", reason.toString()); + assertEquals("ERROR(EXCEPTION,java.lang.Exception: something happened)", reason.toString()); } @Test From 2567c8a72cb123a4e64971fa21dc5e9bb807ee8e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 11:15:44 -0800 Subject: [PATCH 241/327] Revert "Merge pull request #167 from launchdarkly/eb/ch51690/infinite-ttl" This reverts commit a3a7b7acc9dcca00a31c1c0e7a77ec6b4fc313ba, reversing changes made to 9cd52288791afd25bc7ff0dfccc4fff67a87fd4d. --- .../client/FeatureStoreCacheConfig.java | 32 +-- .../client/utils/CachingStoreWrapper.java | 113 +++------ .../client/FeatureStoreCachingTest.java | 9 +- .../client/utils/CachingStoreWrapperTest.java | 219 ++++-------------- 4 files changed, 78 insertions(+), 295 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 9cfedf383..baa1e3942 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -118,31 +118,14 @@ private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleVal /** * Returns true if caching will be enabled. - * @return true if the cache TTL is non-zero + * @return true if the cache TTL is greater than 0 */ public boolean isEnabled() { - return getCacheTime() != 0; + return getCacheTime() > 0; } /** - * Returns true if caching is enabled and does not have a finite TTL. - * @return true if the cache TTL is negative - */ - public boolean isInfiniteTtl() { - return getCacheTime() < 0; - } - - /** - * Returns the cache TTL. - *

    - * If the value is zero, caching is disabled. - *

    - * If the value is negative, data is cached forever (i.e. it will only be read again from the database - * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario - * where multiple processes are sharing the database, and the current process loses connectivity to - * LaunchDarkly while other processes are still receiving updates and writing them to the database, - * the current process will have stale data. - * + * Returns the cache TTL. Caching is enabled if this is greater than zero. * @return the cache TTL in whatever units were specified * @see #getCacheTimeUnit() */ @@ -160,15 +143,6 @@ public TimeUnit getCacheTimeUnit() { /** * Returns the cache TTL converted to milliseconds. - *

    - * If the value is zero, caching is disabled. - *

    - * If the value is negative, data is cached forever (i.e. it will only be read again from the database - * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario - * where multiple processes are sharing the database, and the current process loses connectivity to - * LaunchDarkly while other processes are still receiving updates and writing them to the database, - * the current process will have stale data. - * * @return the TTL in milliseconds */ public long getCacheTimeMillis() { diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index 47de79688..e2e5fa144 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -5,7 +5,6 @@ import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheStats; import com.google.common.cache.LoadingCache; -import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; @@ -36,9 +35,8 @@ 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 FeatureStoreCacheConfig caching; private final LoadingCache> itemCache; - private final LoadingCache, ImmutableMap> allCache; + private final LoadingCache, Map> allCache; private final LoadingCache initCache; private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService executorService; @@ -54,7 +52,6 @@ public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching) { this.core = core; - this.caching = caching; if (!caching.isEnabled()) { itemCache = null; @@ -68,9 +65,9 @@ public Optional load(CacheKey key) throws Exception { return Optional.fromNullable(core.getInternal(key.kind, key.key)); } }; - CacheLoader, ImmutableMap> allLoader = new CacheLoader, ImmutableMap>() { + CacheLoader, Map> allLoader = new CacheLoader, Map>() { @Override - public ImmutableMap load(VersionedDataKind kind) throws Exception { + public Map load(VersionedDataKind kind) throws Exception { return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); } }; @@ -81,18 +78,17 @@ public Boolean load(String key) throws Exception { } }; - if (caching.isInfiniteTtl()) { - itemCache = CacheBuilder.newBuilder().build(itemLoader); - allCache = CacheBuilder.newBuilder().build(allLoader); - executorService = null; - } else if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.EVICT) { + 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; - } else { + 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, @@ -109,11 +105,7 @@ public Boolean load(String key) throws Exception { allCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(allLoader); } - if (caching.isInfiniteTtl()) { - initCache = CacheBuilder.newBuilder().build(initLoader); - } else { - initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); - } + initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); } } @@ -154,34 +146,19 @@ public Map all(VersionedDataKind kind) { public void init(Map, Map> allData) { Map, Map> castMap = // silly generic wildcard problem (Map, Map>)((Map)allData); - try { - core.initInternal(castMap); - } catch (RuntimeException e) { - // Normally, if the underlying store failed to do the update, we do not want to update the cache - - // the idea being that it's better to stay in a consistent state of having old data than to act - // like we have new data but then suddenly fall back to old data when the cache expires. However, - // if the cache TTL is infinite, then it makes sense to update the cache always. - if (allCache != null && itemCache != null && caching.isInfiniteTtl()) { - updateAllCache(castMap); - inited.set(true); - } - throw e; - } + core.initInternal(castMap); + inited.set(true); + if (allCache != null && itemCache != null) { allCache.invalidateAll(); itemCache.invalidateAll(); - updateAllCache(castMap); - } - inited.set(true); - } - - private void updateAllCache(Map, Map> allData) { - for (Map.Entry, Map> e0: allData.entrySet()) { - VersionedDataKind kind = e0.getKey(); - allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); - for (Map.Entry e1: e0.getValue().entrySet()) { - itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); + for (Map.Entry, Map> e0: castMap.entrySet()) { + VersionedDataKind kind = e0.getKey(); + allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); + for (Map.Entry e1: e0.getValue().entrySet()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); + } } } } @@ -193,49 +170,15 @@ public void delete(VersionedDataKind kind, String k @Override public void upsert(VersionedDataKind kind, T item) { - VersionedData newState = item; - RuntimeException failure = null; - try { - newState = core.upsertInternal(kind, item); - } catch (RuntimeException e) { - failure = e; - } - // Normally, if the underlying store failed to do the update, we do not want to update the cache - - // the idea being that it's better to stay in a consistent state of having old data than to act - // like we have new data but then suddenly fall back to old data when the cache expires. However, - // if the cache TTL is infinite, then it makes sense to update the cache always. - if (failure == null || caching.isInfiniteTtl()) { - if (itemCache != null) { - itemCache.put(CacheKey.forItem(kind, item.getKey()), Optional.fromNullable(newState)); - } - if (allCache != null) { - // If the cache has a finite TTL, then we should remove the "all items" cache entry to force - // a reread the next time All is called. However, if it's an infinite TTL, we need to just - // update the item within the existing "all items" entry (since we want things to still work - // even if the underlying store is unavailable). - if (caching.isInfiniteTtl()) { - try { - ImmutableMap cachedAll = allCache.get(kind); - Map newValues = new HashMap<>(); - newValues.putAll(cachedAll); - newValues.put(item.getKey(), newState); - allCache.put(kind, ImmutableMap.copyOf(newValues)); - } catch (Exception e) { - // An exception here means that we did not have a cached value for All, so it tried to query - // the underlying store, which failed (not surprisingly since it just failed a moment ago - // when we tried to do an update). This should not happen in infinite-cache mode, but if it - // does happen, there isn't really anything we can do. - } - } else { - allCache.invalidate(kind); - } - } + VersionedData newState = core.upsertInternal(kind, item); + if (itemCache != null) { + itemCache.put(CacheKey.forItem(kind, item.getKey()), Optional.fromNullable(newState)); } - if (failure != null) { - throw failure; + if (allCache != null) { + allCache.invalidate(kind); } } - + @Override public boolean initialized() { if (inited.get()) { @@ -279,16 +222,16 @@ private VersionedData itemOnlyIfNotDeleted(VersionedData item) { } @SuppressWarnings("unchecked") - private ImmutableMap itemsOnlyIfNotDeleted(Map items) { - ImmutableMap.Builder builder = ImmutableMap.builder(); + private Map itemsOnlyIfNotDeleted(Map items) { + Map ret = new HashMap<>(); if (items != null) { for (Map.Entry item: items.entrySet()) { if (!item.getValue().isDeleted()) { - builder.put(item.getKey(), (T) item.getValue()); + ret.put(item.getKey(), (T) item.getValue()); } } } - return builder.build(); + return ret; } private static class CacheKey { diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java index c9259b622..f8d15f517 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java @@ -10,14 +10,12 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -@SuppressWarnings("javadoc") public class FeatureStoreCachingTest { @Test public void disabledHasExpectedProperties() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.disabled(); assertThat(fsc.getCacheTime(), equalTo(0L)); assertThat(fsc.isEnabled(), equalTo(false)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -27,7 +25,6 @@ public void enabledHasExpectedProperties() { assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -75,15 +72,13 @@ public void zeroTtlMeansDisabled() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(0, TimeUnit.SECONDS); assertThat(fsc.isEnabled(), equalTo(false)); - assertThat(fsc.isInfiniteTtl(), equalTo(false)); } @Test - public void negativeTtlMeansEnabledAndInfinite() { + public void negativeTtlMeansDisabled() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(-1, TimeUnit.SECONDS); - assertThat(fsc.isEnabled(), equalTo(true)); - assertThat(fsc.isInfiniteTtl(), equalTo(true)); + assertThat(fsc.isEnabled(), equalTo(false)); } @Test diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index 6419b6db1..ff9a75d6a 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -5,7 +5,6 @@ import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; -import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -23,46 +22,23 @@ import static org.hamcrest.Matchers.nullValue; import static org.junit.Assume.assumeThat; -@SuppressWarnings("javadoc") @RunWith(Parameterized.class) public class CachingStoreWrapperTest { - private final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); - - private final CachingMode cachingMode; + private final boolean cached; private final MockCore core; private final CachingStoreWrapper wrapper; - static enum CachingMode { - UNCACHED, - CACHED_WITH_FINITE_TTL, - CACHED_INDEFINITELY; - - FeatureStoreCacheConfig toCacheConfig() { - switch (this) { - case CACHED_WITH_FINITE_TTL: - return FeatureStoreCacheConfig.enabled().ttlSeconds(30); - case CACHED_INDEFINITELY: - return FeatureStoreCacheConfig.enabled().ttlSeconds(-1); - default: - return FeatureStoreCacheConfig.disabled(); - } - } - - boolean isCached() { - return this != UNCACHED; - } - }; - @Parameters(name="cached={0}") - public static Iterable data() { - return Arrays.asList(CachingMode.values()); + public static Iterable data() { + return Arrays.asList(new Boolean[] { false, true }); } - public CachingStoreWrapperTest(CachingMode cachingMode) { - this.cachingMode = cachingMode; + public CachingStoreWrapperTest(boolean cached) { + this.cached = cached; this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig()); + this.wrapper = new CachingStoreWrapper(core, cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : + FeatureStoreCacheConfig.disabled()); } @Test @@ -75,7 +51,7 @@ public void get() { core.forceSet(THINGS, itemv2); MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, equalTo(cachingMode.isCached() ? itemv1 : itemv2)); // if cached, we will not see the new underlying value yet + assertThat(result, equalTo(cached ? itemv1 : itemv2)); // if cached, we will not see the new underlying value yet } @Test @@ -88,7 +64,7 @@ public void getDeletedItem() { core.forceSet(THINGS, itemv2); MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv2)); // if cached, we will not see the new underlying value yet + assertThat(result, cached ? nullValue(MockItem.class) : equalTo(itemv2)); // if cached, we will not see the new underlying value yet } @Test @@ -99,12 +75,14 @@ public void getMissingItem() { core.forceSet(THINGS, item); MockItem result = wrapper.get(THINGS, item.key); - assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result + assertThat(result, cached ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result } @Test public void cachedGetUsesValuesFromInit() { - assumeThat(cachingMode.isCached(), is(true)); + if (!cached) { + return; + } MockItem item1 = new MockItem("flag1", 1, false); MockItem item2 = new MockItem("flag2", 1, false); @@ -129,7 +107,7 @@ public void getAll() { core.forceRemove(THINGS, item2.key); items = wrapper.all(THINGS); - if (cachingMode.isCached()) { + if (cached) { assertThat(items, equalTo(expected)); } else { Map expected1 = ImmutableMap.of(item1.key, item1); @@ -151,7 +129,9 @@ public void getAllRemovesDeletedItems() { @Test public void cachedAllUsesValuesFromInit() { - assumeThat(cachingMode.isCached(), is(true)); + if (!cached) { + return; + } MockItem item1 = new MockItem("flag1", 1, false); MockItem item2 = new MockItem("flag2", 1, false); @@ -164,44 +144,33 @@ public void cachedAllUsesValuesFromInit() { Map expected = ImmutableMap.of(item1.key, item1, item2.key, item2); assertThat(items, equalTo(expected)); } - + @Test - public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreInitFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - MockItem item = new MockItem("flag", 1, false); - - core.fakeError = FAKE_ERROR; - try { - wrapper.init(makeData(item)); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); + public void cachedAllUsesFreshValuesIfThereHasBeenAnUpdate() { + if (!cached) { + return; } - core.fakeError = null; - assertThat(wrapper.all(THINGS).size(), equalTo(0)); - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); + 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); - MockItem item = new MockItem("flag", 1, 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); - core.fakeError = FAKE_ERROR; - try { - wrapper.init(makeData(item)); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); - } + // make a change to item2 that bypasses the cache + core.forceSet(THINGS, item2v2); - core.fakeError = null; - Map expected = ImmutableMap.of(item.key, item); - assertThat(wrapper.all(THINGS), equalTo(expected)); + // 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); @@ -215,7 +184,7 @@ public void upsertSuccessful() { // 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 (cachingMode.isCached()) { + if (cached) { MockItem item1v3 = new MockItem("flag", 3, false); core.forceSet(THINGS, item1v3); } @@ -225,7 +194,9 @@ public void upsertSuccessful() { @Test public void cachedUpsertUnsuccessful() { - assumeThat(cachingMode.isCached(), is(true)); + 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 @@ -246,94 +217,6 @@ public void cachedUpsertUnsuccessful() { assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); } - @Test - public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreUpdateFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, false); - - wrapper.init(makeData(itemv1)); - - core.fakeError = FAKE_ERROR; - try { - wrapper.upsert(THINGS, itemv2); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); - } - - core.fakeError = null; - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv1)); // cache still has old item, same as underlying store - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreUpdateFails() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - - MockItem itemv1 = new MockItem("flag", 1, false); - MockItem itemv2 = new MockItem("flag", 2, false); - - wrapper.init(makeData(itemv1)); - - core.fakeError = FAKE_ERROR; - try { - wrapper.upsert(THINGS, itemv2); - Assert.fail("expected exception"); - } catch(RuntimeException e) { - assertThat(e, is(FAKE_ERROR)); - } - - core.fakeError = null; - assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); // underlying store has old item but cache has new item - } - - @Test - public void cachedStoreWithFiniteTtlRemovesCachedAllDataIfOneItemIsUpdated() { - assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - - MockItem item1v1 = new MockItem("item1", 1, false); - MockItem item1v2 = new MockItem("item1", 2, false); - MockItem item2v1 = new MockItem("item2", 1, false); - MockItem item2v2 = new MockItem("item2", 2, false); - - wrapper.init(makeData(item1v1, item2v1)); - wrapper.all(THINGS); // now the All data is cached - - // do an upsert for item1 - this should drop the previous all() data from the cache - wrapper.upsert(THINGS, item1v2); - - // modify item2 directly in the underlying data - core.forceSet(THINGS, item2v2); - - // now, all() should reread the underlying data so we see both changes - Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v2); - assertThat(wrapper.all(THINGS), equalTo(expected)); - } - - @Test - public void cachedStoreWithInfiniteTtlUpdatesCachedAllDataIfOneItemIsUpdated() { - assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - - MockItem item1v1 = new MockItem("item1", 1, false); - MockItem item1v2 = new MockItem("item1", 2, false); - MockItem item2v1 = new MockItem("item2", 1, false); - MockItem item2v2 = new MockItem("item2", 2, false); - - wrapper.init(makeData(item1v1, item2v1)); - wrapper.all(THINGS); // now the All data is cached - - // do an upsert for item1 - this should update the underlying data *and* the cached all() data - wrapper.upsert(THINGS, item1v2); - - // modify item2 directly in the underlying data - core.forceSet(THINGS, item2v2); - - // now, all() should *not* reread the underlying data - we should only see the change to item1 - Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v1); - assertThat(wrapper.all(THINGS), equalTo(expected)); - } - @Test public void delete() { MockItem itemv1 = new MockItem("flag", 1, false); @@ -351,12 +234,12 @@ public void delete() { core.forceSet(THINGS, itemv3); MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv3)); + assertThat(result, cached ? nullValue(MockItem.class) : equalTo(itemv3)); } @Test public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { - assumeThat(cachingMode.isCached(), is(false)); + assumeThat(cached, is(false)); assertThat(wrapper.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -372,7 +255,7 @@ public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { @Test public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { - assumeThat(cachingMode.isCached(), is(false)); + assumeThat(cached, is(false)); assertThat(wrapper.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -385,7 +268,7 @@ public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { @Test public void initializedCanCacheFalseResult() throws Exception { - assumeThat(cachingMode.isCached(), is(true)); + 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, FeatureStoreCacheConfig.enabled().ttlMillis(500))) { @@ -420,7 +303,6 @@ static class MockCore implements FeatureStoreCore { Map, Map> data = new HashMap<>(); boolean inited; int initedQueryCount; - RuntimeException fakeError; @Override public void close() throws IOException { @@ -428,7 +310,6 @@ public void close() throws IOException { @Override public VersionedData getInternal(VersionedDataKind kind, String key) { - maybeThrow(); if (data.containsKey(kind)) { return data.get(kind).get(key); } @@ -437,13 +318,11 @@ public VersionedData getInternal(VersionedDataKind kind, String key) { @Override public Map getAllInternal(VersionedDataKind kind) { - maybeThrow(); return data.get(kind); } @Override public void initInternal(Map, Map> allData) { - maybeThrow(); data.clear(); for (Map.Entry, Map> entry: allData.entrySet()) { data.put(entry.getKey(), new LinkedHashMap(entry.getValue())); @@ -453,7 +332,6 @@ public void initInternal(Map, Map> a @Override public VersionedData upsertInternal(VersionedDataKind kind, VersionedData item) { - maybeThrow(); if (!data.containsKey(kind)) { data.put(kind, new HashMap()); } @@ -468,7 +346,6 @@ public VersionedData upsertInternal(VersionedDataKind kind, VersionedData ite @Override public boolean initializedInternal() { - maybeThrow(); initedQueryCount++; return inited; } @@ -486,12 +363,6 @@ public void forceRemove(VersionedDataKind kind, String key) { data.get(kind).remove(key); } } - - private void maybeThrow() { - if (fakeError != null) { - throw fakeError; - } - } } static class MockItem implements VersionedData { From 6b9f6898ef751d23cfff20845b4a8cda5e69f8b3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 11:16:13 -0800 Subject: [PATCH 242/327] Revert "Merge pull request #165 from launchdarkly/eb/ch59586/integrations-package" This reverts commit 9cd52288791afd25bc7ff0dfccc4fff67a87fd4d, reversing changes made to c273a05f3241b7e482d4fdb78cb9d684529b1fc4. --- .../com/launchdarkly/client/Components.java | 13 +- .../client/RedisFeatureStore.java | 232 ++++++++++++++++-- .../client/RedisFeatureStoreBuilder.java | 81 +++--- .../client/files/DataBuilder.java | 32 +++ .../launchdarkly/client/files/DataLoader.java | 58 +++++ .../client/files/DataLoaderException.java | 43 ++++ .../client/files/FileComponents.java | 95 ++++++- .../FileDataSource.java} | 104 +------- .../client/files/FileDataSourceFactory.java | 28 ++- .../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/package-info.java | 4 +- .../client/integrations/FileData.java | 116 --------- .../integrations/FileDataSourceBuilder.java | 83 ------- .../integrations/FileDataSourceParsing.java | 223 ----------------- .../client/integrations/Redis.java | 23 -- .../integrations/RedisDataStoreBuilder.java | 187 -------------- .../integrations/RedisDataStoreImpl.java | 196 --------------- .../client/integrations/package-info.java | 12 - .../client/RedisFeatureStoreBuilderTest.java | 106 ++++++++ ...reTest.java => RedisFeatureStoreTest.java} | 19 +- .../ClientWithFileDataSourceTest.java | 17 +- .../DataLoaderTest.java | 16 +- .../FileDataSourceTest.java | 24 +- .../FlagFileParserTestBase.java | 17 +- .../client/files/JsonFlagFileParserTest.java | 7 + .../TestData.java} | 8 +- .../client/files/YamlFlagFileParserTest.java | 7 + .../integrations/FlagFileParserJsonTest.java | 10 - .../integrations/FlagFileParserYamlTest.java | 10 - .../RedisFeatureStoreBuilderTest.java | 53 ---- .../integrations/RedisFeatureStoreTest.java | 62 ----- 35 files changed, 886 insertions(+), 1200 deletions(-) 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 rename src/main/java/com/launchdarkly/client/{integrations/FileDataSourceImpl.java => files/FileDataSource.java} (56%) 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 delete mode 100644 src/main/java/com/launchdarkly/client/integrations/FileData.java delete mode 100644 src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java delete mode 100644 src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java delete mode 100644 src/main/java/com/launchdarkly/client/integrations/Redis.java delete mode 100644 src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java delete mode 100644 src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java delete mode 100644 src/main/java/com/launchdarkly/client/integrations/package-info.java create mode 100644 src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java rename src/test/java/com/launchdarkly/client/{DeprecatedRedisFeatureStoreTest.java => RedisFeatureStoreTest.java} (66%) rename src/test/java/com/launchdarkly/client/{integrations => files}/ClientWithFileDataSourceTest.java (65%) rename src/test/java/com/launchdarkly/client/{integrations => files}/DataLoaderTest.java (86%) rename src/test/java/com/launchdarkly/client/{integrations => files}/FileDataSourceTest.java (88%) rename src/test/java/com/launchdarkly/client/{integrations => files}/FlagFileParserTestBase.java (76%) create mode 100644 src/test/java/com/launchdarkly/client/files/JsonFlagFileParserTest.java rename src/test/java/com/launchdarkly/client/{integrations/FileDataSourceTestData.java => files/TestData.java} (90%) create mode 100644 src/test/java/com/launchdarkly/client/files/YamlFlagFileParserTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index fb48bc1dd..e95785915 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -41,23 +41,22 @@ public static FeatureStoreFactory inMemoryFeatureStore() { } /** - * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. + * 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 - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()}. + * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ - @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore() { return new RedisFeatureStoreBuilder(); } /** - * Deprecated name for {@link com.launchdarkly.client.integrations.Redis#dataStore()}. + * 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 - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} and - * {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder#uri(URI)}. + * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ - @Deprecated public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { return new RedisFeatureStoreBuilder(redisUri); } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java index 9713704a7..55091fa40 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStore.java @@ -1,58 +1,73 @@ package com.launchdarkly.client; +import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.CacheStats; import com.launchdarkly.client.utils.CachingStoreWrapper; +import com.launchdarkly.client.utils.FeatureStoreCore; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import 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; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Transaction; +import redis.clients.util.JedisURIHelper; + /** - * Deprecated implementation class for the Redis-based persistent data store. - *

    - * Instead of referencing this class directly, use {@link com.launchdarkly.client.integrations.Redis#dataStore()} to obtain a builder object. - * - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} + * An implementation of {@link FeatureStore} backed by Redis. Also + * supports an optional in-memory cache configuration that can be used to improve performance. */ -@Deprecated public class RedisFeatureStore implements FeatureStore { - // The actual implementation is now in the com.launchdarkly.integrations package. This class remains - // visible for backward compatibility, but simply delegates to an instance of the underlying store. - - private final FeatureStore wrappedStore; + private static final Logger logger = LoggerFactory.getLogger(RedisFeatureStore.class); + + // 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; @Override public void init(Map, Map> allData) { - wrappedStore.init(allData); + wrapper.init(allData); } @Override public T get(VersionedDataKind kind, String key) { - return wrappedStore.get(kind, key); + return wrapper.get(kind, key); } @Override public Map all(VersionedDataKind kind) { - return wrappedStore.all(kind); + return wrapper.all(kind); } @Override public void upsert(VersionedDataKind kind, T item) { - wrappedStore.upsert(kind, item); + wrapper.upsert(kind, item); } @Override public void delete(VersionedDataKind kind, String key, int version) { - wrappedStore.delete(kind, key, version); + wrapper.delete(kind, key, version); } @Override public boolean initialized() { - return wrappedStore.initialized(); + return wrapper.initialized(); } @Override public void close() throws IOException { - wrappedStore.close(); + wrapper.close(); } /** @@ -61,7 +76,7 @@ public void close() throws IOException { * @return the cache statistics object. */ public CacheStats getCacheStats() { - return ((CachingStoreWrapper)wrappedStore).getCacheStats(); + return wrapper.getCacheStats(); } /** @@ -72,15 +87,192 @@ public CacheStats getCacheStats() { * @param builder the configured builder to construct the store with. */ protected RedisFeatureStore(RedisFeatureStoreBuilder builder) { - wrappedStore = builder.wrappedBuilder.createFeatureStore(); + // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, + // the overloads that accept a URI do not accept the other parameters we need to set, so we need + // to decompose the URI. + String host = builder.uri.getHost(); + int port = builder.uri.getPort(); + String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; + int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); + boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); + + String extra = tls ? " with TLS" : ""; + if (password != null) { + extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; + } + logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); + + JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); + JedisPool pool = new JedisPool(poolConfig, + host, + port, + builder.connectTimeout, + builder.socketTimeout, + password, + database, + null, // clientName + tls, + null, // sslSocketFactory + null, // sslParameters + null // hostnameVerifier + ); + + String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? + RedisFeatureStoreBuilder.DEFAULT_PREFIX : + builder.prefix; + + this.core = new Core(pool, prefix); + this.wrapper = CachingStoreWrapper.builder(this.core).caching(builder.caching) + .build(); } /** * Creates a new store instance that connects to Redis with a default connection (localhost port 6379) and no in-memory cache. * @deprecated Please use {@link Components#redisFeatureStore()} instead. */ - @Deprecated public RedisFeatureStore() { this(new RedisFeatureStoreBuilder().caching(FeatureStoreCacheConfig.disabled())); } + + 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; + } + + @Override + 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 item; + } + } + + @Override + public Map getAllInternal(VersionedDataKind kind) { + try (Jedis jedis = pool.getResource()) { + Map allJson = jedis.hgetAll(itemsKey(kind)); + Map result = new HashMap<>(); + + for (Map.Entry entry : allJson.entrySet()) { + VersionedData item = unmarshalJson(kind, entry.getValue()); + result.put(entry.getKey(), item); + } + return result; + } + } + + @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(), marshalJson(item)); + } + } + + t.set(initedKey(), ""); + t.exec(); + } + } + + @Override + public VersionedData upsertInternal(VersionedDataKind kind, VersionedData 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 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 initializedInternal() { + try (Jedis jedis = pool.getResource()) { + return jedis.exists(initedKey()); + } + } + + @Override + public void close() throws IOException { + logger.info("Closing LaunchDarkly RedisFeatureStore"); + pool.destroy(); + } + + @VisibleForTesting + void setUpdateListener(UpdateListener updateListener) { + this.updateListener = updateListener; + } + + private String itemsKey(VersionedDataKind kind) { + return prefix + ":" + kind.getNamespace(); + } + + private String initedKey() { + return prefix + ":$inited"; + } + + private T getRedis(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; + } + + return unmarshalJson(kind, json); + } + } + + static interface UpdateListener { + void aboutToUpdate(String baseKey, String itemKey); + } + + @VisibleForTesting + void setUpdateListener(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 d84e3aa58..7c5661e82 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -1,23 +1,25 @@ package com.launchdarkly.client; -import com.launchdarkly.client.integrations.Redis; -import com.launchdarkly.client.integrations.RedisDataStoreBuilder; +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Protocol; import java.net.URI; import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; -import redis.clients.jedis.JedisPoolConfig; - /** - * Deprecated builder class for the Redis-based persistent data store. - *

    - * The replacement for this class is {@link com.launchdarkly.client.integrations.RedisDataStoreBuilder}. - * This class is retained for backward compatibility and will be removed in a future version. - * - * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} + * A builder for configuring the Redis-based persistent feature store. + * + * Obtain an instance of this class by calling {@link Components#redisFeatureStore()} or {@link Components#redisFeatureStore(URI)}. + * Builder calls can be chained, for example: + * + *

    
    + * FeatureStore store = Components.redisFeatureStore()
    + *      .database(1)
    + *      .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
    + *      .build();
    + * 
    */ -@Deprecated public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { /** * The default value for the Redis URI: {@code redis://localhost:6379} @@ -38,21 +40,25 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { */ public static final long DEFAULT_CACHE_TIME_SECONDS = FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS; - final RedisDataStoreBuilder wrappedBuilder; - - // We have to keep track of these caching parameters separately in order to support some deprecated setters + final URI uri; + String prefix = DEFAULT_PREFIX; + int connectTimeout = Protocol.DEFAULT_TIMEOUT; + int socketTimeout = Protocol.DEFAULT_TIMEOUT; + Integer database = null; + String password = null; + boolean tls = false; FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - boolean refreshStaleValues = false; + boolean refreshStaleValues = false; // this and asyncRefresh are redundant with FeatureStoreCacheConfig, but are used by deprecated setters boolean asyncRefresh = false; + JedisPoolConfig poolConfig = null; - // These constructors are called only from Components + // These constructors are called only from Implementations RedisFeatureStoreBuilder() { - wrappedBuilder = Redis.dataStore(); + this.uri = DEFAULT_URI; } RedisFeatureStoreBuilder(URI uri) { - this(); - wrappedBuilder.uri(uri); + this.uri = uri; } /** @@ -63,10 +69,8 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. */ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { - this(); - wrappedBuilder.uri(uri); - caching = caching.ttlSeconds(cacheTimeSecs); - wrappedBuilder.caching(caching); + this.uri = uri; + this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); } /** @@ -80,10 +84,8 @@ public RedisFeatureStoreBuilder(URI uri, long cacheTimeSecs) { * @deprecated Please use {@link Components#redisFeatureStore(java.net.URI)}. */ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cacheTimeSecs) throws URISyntaxException { - this(); - wrappedBuilder.uri(new URI(scheme, null, host, port, null, null, null)); - caching = caching.ttlSeconds(cacheTimeSecs); - wrappedBuilder.caching(caching); + this.uri = new URI(scheme, null, host, port, null, null, null); + this.cacheTime(cacheTimeSecs, TimeUnit.SECONDS); } /** @@ -98,7 +100,7 @@ public RedisFeatureStoreBuilder(String scheme, String host, int port, long cache * @since 4.7.0 */ public RedisFeatureStoreBuilder database(Integer database) { - wrappedBuilder.database(database); + this.database = database; return this; } @@ -114,7 +116,7 @@ public RedisFeatureStoreBuilder database(Integer database) { * @since 4.7.0 */ public RedisFeatureStoreBuilder password(String password) { - wrappedBuilder.password(password); + this.password = password; return this; } @@ -131,7 +133,7 @@ public RedisFeatureStoreBuilder password(String password) { * @since 4.7.0 */ public RedisFeatureStoreBuilder tls(boolean tls) { - wrappedBuilder.tls(tls); + this.tls = tls; return this; } @@ -147,7 +149,6 @@ public RedisFeatureStoreBuilder tls(boolean tls) { */ public RedisFeatureStoreBuilder caching(FeatureStoreCacheConfig caching) { this.caching = caching; - wrappedBuilder.caching(caching); return this; } @@ -186,14 +187,13 @@ public RedisFeatureStoreBuilder asyncRefresh(boolean enabled) { 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 (refreshStaleValues) { - caching = caching.staleValuesPolicy(this.asyncRefresh ? + if (this.refreshStaleValues) { + this.caching = this.caching.staleValuesPolicy(this.asyncRefresh ? FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC : FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH); } else { - caching = caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); + this.caching = this.caching.staleValuesPolicy(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT); } - wrappedBuilder.caching(caching); } /** @@ -203,7 +203,7 @@ private void updateCachingStaleValuesPolicy() { * @return the builder */ public RedisFeatureStoreBuilder prefix(String prefix) { - wrappedBuilder.prefix(prefix); + this.prefix = prefix; return this; } @@ -218,9 +218,8 @@ public RedisFeatureStoreBuilder prefix(String prefix) { * @deprecated use {@link #caching(FeatureStoreCacheConfig)} and {@link FeatureStoreCacheConfig#ttl(long, TimeUnit)}. */ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { - caching = caching.ttl(cacheTime, timeUnit) + this.caching = this.caching.ttl(cacheTime, timeUnit) .staleValuesPolicy(this.caching.getStaleValuesPolicy()); - wrappedBuilder.caching(caching); return this; } @@ -231,7 +230,7 @@ public RedisFeatureStoreBuilder cacheTime(long cacheTime, TimeUnit timeUnit) { * @return the builder */ public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { - wrappedBuilder.poolConfig(poolConfig); + this.poolConfig = poolConfig; return this; } @@ -244,7 +243,7 @@ public RedisFeatureStoreBuilder poolConfig(JedisPoolConfig poolConfig) { * @return the builder */ public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { - wrappedBuilder.connectTimeout(connectTimeout, timeUnit); + this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); return this; } @@ -257,7 +256,7 @@ public RedisFeatureStoreBuilder connectTimeout(int connectTimeout, TimeUnit time * @return the builder */ public RedisFeatureStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { - wrappedBuilder.socketTimeout(socketTimeout, timeUnit); + this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); return this; } 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 index 63a575555..f893b7f47 100644 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -1,11 +1,100 @@ package com.launchdarkly.client.files; /** - * Deprecated entry point for the file data source. + * 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()
    + *         .dataSource(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 {@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: + *

    + *     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): + *

    + * {
    + *     "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 - * @deprecated Use {@link com.launchdarkly.client.integrations.FileData}. */ -@Deprecated public abstract class FileComponents { /** * Creates a {@link FileDataSourceFactory} which you can use to configure the file data diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java b/src/main/java/com/launchdarkly/client/files/FileDataSource.java similarity index 56% rename from src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java rename to src/main/java/com/launchdarkly/client/files/FileDataSource.java index cd2244564..e040e7902 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceImpl.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSource.java @@ -1,34 +1,21 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.client.files; import com.google.common.util.concurrent.Futures; -import com.google.gson.JsonElement; import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.UpdateProcessor; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFactory; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.FileSystems; -import java.nio.file.Files; 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.ArrayList; -import java.util.HashMap; import java.util.HashSet; -import java.util.List; -import java.util.Map; import java.util.Set; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -38,20 +25,20 @@ import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; /** - * Implements taking flag data from files and putting it into the data store, at startup time and + * Implements taking flag data from files and putting it into the feature store, at startup time and * optionally whenever files change. */ -final class FileDataSourceImpl implements UpdateProcessor { - private static final Logger logger = LoggerFactory.getLogger(FileDataSourceImpl.class); +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; - FileDataSourceImpl(FeatureStore store, List sources, boolean autoUpdate) { + FileDataSource(FeatureStore store, DataLoader dataLoader, boolean autoUpdate) { this.store = store; - this.dataLoader = new DataLoader(sources); + this.dataLoader = dataLoader; FileWatcher fw = null; if (autoUpdate) { @@ -78,7 +65,7 @@ public Future start() { if (fileWatcher != null) { fileWatcher.start(new Runnable() { public void run() { - FileDataSourceImpl.this.reload(); + FileDataSource.this.reload(); } }); } @@ -90,7 +77,7 @@ private boolean reload() { DataBuilder builder = new DataBuilder(); try { dataLoader.load(builder); - } catch (FileDataException e) { + } catch (DataLoaderException e) { logger.error(e.getDescription()); return false; } @@ -114,7 +101,7 @@ public void close() throws IOException { /** * If auto-updating is enabled, this component watches for file changes on a worker thread. */ - private static final class FileWatcher implements Runnable { + private static class FileWatcher implements Runnable { private final WatchService watchService; private final Set watchedFilePaths; private Runnable fileModifiedAction; @@ -178,7 +165,7 @@ public void run() { public void start(Runnable fileModifiedAction) { this.fileModifiedAction = fileModifiedAction; - thread = new Thread(this, FileDataSourceImpl.class.getName()); + thread = new Thread(this, FileDataSource.class.getName()); thread.setDaemon(true); thread.start(); } @@ -190,75 +177,4 @@ public void stop() { } } } - - /** - * 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. - */ - static 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 FileDataException - { - 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 (FileDataException e) { - throw new FileDataException(e.getMessage(), e.getCause(), p); - } catch (IOException e) { - throw new FileDataException(null, e, p); - } - } - } - } - - /** - * 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. - */ - static final class DataBuilder { - private final Map, Map> allData = new HashMap<>(); - - public Map, Map> build() { - return allData; - } - - public void add(VersionedDataKind kind, VersionedData item) throws FileDataException { - @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 FileDataException("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/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index ded4a2dd5..8f0d36cf0 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -4,21 +4,25 @@ import com.launchdarkly.client.LDConfig; import com.launchdarkly.client.UpdateProcessor; import com.launchdarkly.client.UpdateProcessorFactory; -import com.launchdarkly.client.integrations.FileDataSourceBuilder; -import com.launchdarkly.client.integrations.FileData; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; /** - * Deprecated name for {@link FileDataSourceBuilder}. Use {@link FileData#dataSource()} to obtain the - * new builder. + * 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#dataSource(UpdateProcessorFactory)}. + *

    + * For more details, see {@link FileComponents}. * * @since 4.5.0 - * @deprecated */ public class FileDataSourceFactory implements UpdateProcessorFactory { - private final FileDataSourceBuilder wrappedBuilder = new FileDataSourceBuilder(); + 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 @@ -32,7 +36,9 @@ public class FileDataSourceFactory implements UpdateProcessorFactory { * @throws InvalidPathException if one of the parameters is not a valid file path */ public FileDataSourceFactory filePaths(String... filePaths) throws InvalidPathException { - wrappedBuilder.filePaths(filePaths); + for (String p: filePaths) { + sources.add(Paths.get(p)); + } return this; } @@ -46,7 +52,9 @@ public FileDataSourceFactory filePaths(String... filePaths) throws InvalidPathEx * @return the same factory object */ public FileDataSourceFactory filePaths(Path... filePaths) { - wrappedBuilder.filePaths(filePaths); + for (Path p: filePaths) { + sources.add(p); + } return this; } @@ -61,7 +69,7 @@ public FileDataSourceFactory filePaths(Path... filePaths) { * @return the same factory object */ public FileDataSourceFactory autoUpdate(boolean autoUpdate) { - wrappedBuilder.autoUpdate(autoUpdate); + this.autoUpdate = autoUpdate; return this; } @@ -70,6 +78,6 @@ public FileDataSourceFactory autoUpdate(boolean autoUpdate) { */ @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return wrappedBuilder.createUpdateProcessor(sdkKey, config, 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/main/java/com/launchdarkly/client/files/package-info.java b/src/main/java/com/launchdarkly/client/files/package-info.java index da8abb785..a5a3eafa4 100644 --- a/src/main/java/com/launchdarkly/client/files/package-info.java +++ b/src/main/java/com/launchdarkly/client/files/package-info.java @@ -1,4 +1,6 @@ /** - * Deprecated package replaced by {@link com.launchdarkly.client.integrations.FileData}. + * Package for the file data source component, which may be useful in tests. + *

    + * The entry point is {@link com.launchdarkly.client.files.FileComponents}. */ package com.launchdarkly.client.files; diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/client/integrations/FileData.java deleted file mode 100644 index a6f65f3e2..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/FileData.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.launchdarkly.client.integrations; - -/** - * Integration between the LaunchDarkly SDK and file data. - *

    - * The file data source 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. See {@link #dataSource()} for details. - * - * @since 4.11.0 - */ -public abstract class FileData { - /** - * Creates a {@link FileDataSourceBuilder} which you can use to configure the file data source. - * This allows you to use local files as a source of feature flag state, instead of using an actual - * LaunchDarkly connection. - *

    - * This object can be modified with {@link FileDataSourceBuilder} methods for any desired - * custom settings, before including it in the SDK configuration with - * {@link com.launchdarkly.client.LDConfig.Builder#dataSource(com.launchdarkly.client.UpdateProcessorFactory)}. - *

    - * At a minimum, you will want to call {@link FileDataSourceBuilder#filePaths(String...)} to specify - * your data file(s); you can also use {@link FileDataSourceBuilder#autoUpdate(boolean)} to - * specify that flags should be reloaded when a file is modified. See {@link FileDataSourceBuilder} - * for all configuration options. - *

    -   *     FileDataSourceFactory f = FileData.dataSource()
    -   *         .filePaths("./testData/flags.json")
    -   *         .autoUpdate(true);
    -   *     LDConfig config = new LDConfig.Builder()
    -   *         .dataSource(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 {@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: - *

    -   *     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): - *

    -   * {
    -   *     "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. - * - * @return a data source configuration object - * @since 4.11.0 - */ - public static FileDataSourceBuilder dataSource() { - return new FileDataSourceBuilder(); - } - - private FileData() {} -} diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java deleted file mode 100644 index 4c3cd1993..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.launchdarkly.client.integrations; - -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 FileData#dataSource()}, - * call the builder method {@link #filePaths(String...)} to specify file path(s), - * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}. - *

    - * For more details, see {@link FileData}. - * - * @since 4.11.0 - */ -public final class FileDataSourceBuilder 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 FileDataSourceBuilder 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 FileDataSourceBuilder 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. - *

    - * 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 - */ - public FileDataSourceBuilder 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 FileDataSourceImpl(featureStore, sources, autoUpdate); - } -} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java deleted file mode 100644 index 08083e4d9..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceParsing.java +++ /dev/null @@ -1,223 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonSyntaxException; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; - -import org.yaml.snakeyaml.Yaml; -import org.yaml.snakeyaml.error.YAMLException; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.nio.file.Path; -import java.util.Map; - -abstract class FileDataSourceParsing { - /** - * 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") - static final class FileDataException extends Exception { - private final Path filePath; - - public FileDataException(String message, Throwable cause, Path filePath) { - super(message, cause); - this.filePath = filePath; - } - - public FileDataException(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(); - } - } - - /** - * 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}. - */ - static 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; - } - } - - static abstract class FlagFileParser { - private static final FlagFileParser jsonParser = new JsonFlagFileParser(); - private static final FlagFileParser yamlParser = new YamlFlagFileParser(); - - public abstract FlagFileRep parse(InputStream input) throws FileDataException, 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; - } - } - } - } - - static final class JsonFlagFileParser extends FlagFileParser { - private static final Gson gson = new Gson(); - - @Override - public FlagFileRep parse(InputStream input) throws FileDataException, IOException { - try { - return parseJson(gson.fromJson(new InputStreamReader(input), JsonElement.class)); - } catch (JsonSyntaxException e) { - throw new FileDataException("cannot parse JSON", e); - } - } - - public FlagFileRep parseJson(JsonElement tree) throws FileDataException, IOException { - try { - return gson.fromJson(tree, FlagFileRep.class); - } catch (JsonSyntaxException e) { - throw new FileDataException("cannot parse JSON", e); - } - } - } - - /** - * 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) - *
    - */ - static 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 FileDataException, IOException { - Object root; - try { - root = yaml.load(input); - } catch (YAMLException e) { - throw new FileDataException("unable to parse YAML", e); - } - JsonElement jsonRoot = gson.toJsonTree(root); - return jsonFileParser.parseJson(jsonRoot); - } - } - - /** - * 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. - */ - static final class FlagFactory { - private static final Gson gson = new Gson(); - - static VersionedData flagFromJson(String jsonString) { - return flagFromJson(gson.fromJson(jsonString, JsonElement.class)); - } - - 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. - */ - 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); - } - - static VersionedData segmentFromJson(String jsonString) { - return segmentFromJson(gson.fromJson(jsonString, JsonElement.class)); - } - - static VersionedData segmentFromJson(JsonElement jsonTree) { - return gson.fromJson(jsonTree, VersionedDataKind.SEGMENTS.getItemClass()); - } - } -} diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java deleted file mode 100644 index 050b581bb..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/Redis.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.launchdarkly.client.integrations; - -/** - * Integration between the LaunchDarkly SDK and Redis. - * - * @since 4.11.0 - */ -public abstract class Redis { - /** - * Returns a builder object for creating a Redis-backed data store. - *

    - * This object can be modified with {@link RedisDataStoreBuilder} methods for any desired - * custom settings, before including it in the SDK configuration with - * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. - * - * @return a data store configuration object - */ - public static RedisDataStoreBuilder dataStore() { - return new RedisDataStoreBuilder(); - } - - private Redis() {} -} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java deleted file mode 100644 index bbc50491d..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.FeatureStoreFactory; -import com.launchdarkly.client.utils.CachingStoreWrapper; - -import java.net.URI; -import java.util.concurrent.TimeUnit; - -import static com.google.common.base.Preconditions.checkNotNull; - -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -/** - * A builder for configuring the Redis-based persistent data store. - *

    - * Obtain an instance of this class by calling {@link Redis#dataStore()}. After calling its methods - * to specify any desired custom settings, you can pass it directly into the SDK configuration with - * {@link com.launchdarkly.client.LDConfig.Builder#dataStore(com.launchdarkly.client.FeatureStoreFactory)}. - * You do not need to call {@link #createFeatureStore()} yourself to build the actual data store; that - * will be done by the SDK. - *

    - * Builder calls can be chained, for example: - * - *

    
    - * LDConfig config = new LDConfig.Builder()
    - *      .dataStore(
    - *           Redis.dataStore()
    - *               .database(1)
    - *               .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
    - *      )
    - *      .build();
    - * 
    - * - * @since 4.11.0 - */ -public final class RedisDataStoreBuilder implements FeatureStoreFactory { - /** - * The default value for the Redis URI: {@code redis://localhost:6379} - */ - public static final URI DEFAULT_URI = URI.create("redis://localhost:6379"); - - /** - * The default value for {@link #prefix(String)}. - */ - public static final String DEFAULT_PREFIX = "launchdarkly"; - - URI uri = DEFAULT_URI; - String prefix = DEFAULT_PREFIX; - int connectTimeout = Protocol.DEFAULT_TIMEOUT; - int socketTimeout = Protocol.DEFAULT_TIMEOUT; - Integer database = null; - String password = null; - boolean tls = false; - FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - JedisPoolConfig poolConfig = null; - - // These constructors are called only from Implementations - RedisDataStoreBuilder() { - } - - /** - * Specifies the database number to use. - *

    - * The database number can also be specified in the Redis URI, in the form {@code redis://host:port/NUMBER}. Any - * non-null value that you set with {@link #database(Integer)} will override the URI. - * - * @param database the database number, or null to fall back to the URI or the default - * @return the builder - */ - public RedisDataStoreBuilder database(Integer database) { - this.database = database; - return this; - } - - /** - * Specifies a password that will be sent to Redis in an AUTH command. - *

    - * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port}. Any - * password that you set with {@link #password(String)} will override the URI. - * - * @param password the password - * @return the builder - */ - public RedisDataStoreBuilder password(String password) { - this.password = password; - return this; - } - - /** - * Optionally enables TLS for secure connections to Redis. - *

    - * This is equivalent to specifying a Redis URI that begins with {@code rediss:} rather than {@code redis:}. - *

    - * Note that not all Redis server distributions support TLS. - * - * @param tls true to enable TLS - * @return the builder - */ - public RedisDataStoreBuilder tls(boolean tls) { - this.tls = tls; - return this; - } - - /** - * Specifies a Redis host URI other than {@link #DEFAULT_URI}. - * - * @param redisUri the URI of the Redis host - * @return the builder - */ - public RedisDataStoreBuilder uri(URI redisUri) { - this.uri = checkNotNull(uri); - return this; - } - - /** - * Specifies whether local caching should be enabled and if so, sets the cache properties. Local - * caching is enabled by default; see {@link FeatureStoreCacheConfig#DEFAULT}. To disable it, pass - * {@link FeatureStoreCacheConfig#disabled()} to this method. - * - * @param caching a {@link FeatureStoreCacheConfig} object specifying caching parameters - * @return the builder - */ - public RedisDataStoreBuilder caching(FeatureStoreCacheConfig caching) { - this.caching = caching; - return this; - } - - /** - * Optionally configures the namespace prefix for all keys stored in Redis. - * - * @param prefix the namespace prefix - * @return the builder - */ - public RedisDataStoreBuilder prefix(String prefix) { - this.prefix = prefix; - return this; - } - - /** - * Optional override if you wish to specify your own configuration to the underlying Jedis pool. - * - * @param poolConfig the Jedis pool configuration. - * @return the builder - */ - public RedisDataStoreBuilder poolConfig(JedisPoolConfig poolConfig) { - this.poolConfig = poolConfig; - return this; - } - - /** - * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to - * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} - * - * @param connectTimeout the timeout - * @param timeUnit the time unit for the timeout - * @return the builder - */ - public RedisDataStoreBuilder connectTimeout(int connectTimeout, TimeUnit timeUnit) { - this.connectTimeout = (int) timeUnit.toMillis(connectTimeout); - return this; - } - - /** - * Optional override which sets the connection timeout for the underlying Jedis pool which otherwise defaults to - * {@link redis.clients.jedis.Protocol#DEFAULT_TIMEOUT} - * - * @param socketTimeout the socket timeout - * @param timeUnit the time unit for the timeout - * @return the builder - */ - public RedisDataStoreBuilder socketTimeout(int socketTimeout, TimeUnit timeUnit) { - this.socketTimeout = (int) timeUnit.toMillis(socketTimeout); - return this; - } - - /** - * Called internally by the SDK to create the actual data store instance. - * @return the data store configured by this builder - */ - public FeatureStore createFeatureStore() { - RedisDataStoreImpl core = new RedisDataStoreImpl(this); - return CachingStoreWrapper.builder(core).caching(this.caching).build(); - } -} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java deleted file mode 100644 index 24e3968b7..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ /dev/null @@ -1,196 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.google.common.annotations.VisibleForTesting; -import com.launchdarkly.client.VersionedData; -import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.utils.FeatureStoreCore; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import 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; -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Transaction; -import redis.clients.util.JedisURIHelper; - -class RedisDataStoreImpl implements FeatureStoreCore { - private static final Logger logger = LoggerFactory.getLogger(RedisDataStoreImpl.class); - - private final JedisPool pool; - private final String prefix; - private UpdateListener updateListener; - - RedisDataStoreImpl(RedisDataStoreBuilder builder) { - // There is no builder for JedisPool, just a large number of constructor overloads. Unfortunately, - // the overloads that accept a URI do not accept the other parameters we need to set, so we need - // to decompose the URI. - String host = builder.uri.getHost(); - int port = builder.uri.getPort(); - String password = builder.password == null ? JedisURIHelper.getPassword(builder.uri) : builder.password; - int database = builder.database == null ? JedisURIHelper.getDBIndex(builder.uri): builder.database.intValue(); - boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); - - String extra = tls ? " with TLS" : ""; - if (password != null) { - extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; - } - logger.info(String.format("Connecting to Redis feature store at %s:%d/%d%s", host, port, database, extra)); - - JedisPoolConfig poolConfig = (builder.poolConfig != null) ? builder.poolConfig : new JedisPoolConfig(); - JedisPool pool = new JedisPool(poolConfig, - host, - port, - builder.connectTimeout, - builder.socketTimeout, - password, - database, - null, // clientName - tls, - null, // sslSocketFactory - null, // sslParameters - null // hostnameVerifier - ); - - String prefix = (builder.prefix == null || builder.prefix.isEmpty()) ? - RedisDataStoreBuilder.DEFAULT_PREFIX : - builder.prefix; - - this.pool = pool; - this.prefix = prefix; - } - - @Override - 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 item; - } - } - - @Override - public Map getAllInternal(VersionedDataKind kind) { - try (Jedis jedis = pool.getResource()) { - Map allJson = jedis.hgetAll(itemsKey(kind)); - Map result = new HashMap<>(); - - for (Map.Entry entry : allJson.entrySet()) { - VersionedData item = unmarshalJson(kind, entry.getValue()); - result.put(entry.getKey(), item); - } - return result; - } - } - - @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(), marshalJson(item)); - } - } - - t.set(initedKey(), ""); - t.exec(); - } - } - - @Override - public VersionedData upsertInternal(VersionedDataKind kind, VersionedData 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 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 initializedInternal() { - try (Jedis jedis = pool.getResource()) { - return jedis.exists(initedKey()); - } - } - - @Override - public void close() throws IOException { - logger.info("Closing LaunchDarkly RedisFeatureStore"); - pool.destroy(); - } - - @VisibleForTesting - void setUpdateListener(UpdateListener updateListener) { - this.updateListener = updateListener; - } - - private String itemsKey(VersionedDataKind kind) { - return prefix + ":" + kind.getNamespace(); - } - - private String initedKey() { - return prefix + ":$inited"; - } - - private T getRedis(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; - } - - return unmarshalJson(kind, json); - } - - static interface UpdateListener { - void aboutToUpdate(String baseKey, String itemKey); - } -} diff --git a/src/main/java/com/launchdarkly/client/integrations/package-info.java b/src/main/java/com/launchdarkly/client/integrations/package-info.java deleted file mode 100644 index 589a2c63a..000000000 --- a/src/main/java/com/launchdarkly/client/integrations/package-info.java +++ /dev/null @@ -1,12 +0,0 @@ -/** - * This package contains integration tools for connecting the SDK to other software components. - *

    - * In the current main LaunchDarkly Java SDK library, this package contains {@link com.launchdarkly.client.integrations.Redis} - * (for using Redis as a store for flag data) and {@link com.launchdarkly.client.integrations.FileData} - * (for reading flags from a file in testing). Other SDK add-on libraries, such as database integrations, - * will define their classes in {@code com.launchdarkly.client.integrations} as well. - *

    - * The general pattern for factory methods in this package is {@code ToolName#componentType()}, - * such as {@code Redis#dataStore()} or {@code FileData#dataSource()}. - */ -package com.launchdarkly.client.integrations; diff --git a/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java new file mode 100644 index 000000000..64fb15068 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreBuilderTest.java @@ -0,0 +1,106 @@ +package com.launchdarkly.client; + +import org.junit.Test; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import redis.clients.jedis.JedisPoolConfig; +import redis.clients.jedis.Protocol; + +public class RedisFeatureStoreBuilderTest { + @Test + public void testDefaultValues() { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(); + assertEquals(RedisFeatureStoreBuilder.DEFAULT_URI, conf.uri); + assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); + assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); + assertNull(conf.poolConfig); + } + + @Test + public void testConstructorSpecifyingUri() { + URI uri = URI.create("redis://host:1234"); + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder(uri); + assertEquals(uri, conf.uri); + assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); + assertEquals(RedisFeatureStoreBuilder.DEFAULT_PREFIX, conf.prefix); + assertNull(conf.poolConfig); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedUriBuildingConstructor() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder("badscheme", "example", 1234, 100); + assertEquals(URI.create("badscheme://example:1234"), conf.uri); + assertEquals(100, conf.caching.getCacheTime()); + assertEquals(TimeUnit.SECONDS, conf.caching.getCacheTimeUnit()); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); + assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); + assertEquals(FeatureStoreCacheConfig.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(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH, conf.caching.getStaleValuesPolicy()); + } + + @SuppressWarnings("deprecation") + @Test + public void testAsyncRefresh() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().refreshStaleValues(true).asyncRefresh(true); + assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.REFRESH_ASYNC, conf.caching.getStaleValuesPolicy()); + } + + @SuppressWarnings("deprecation") + @Test + public void testRefreshStaleValuesWithoutAsyncRefresh() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().asyncRefresh(true); + assertEquals(FeatureStoreCacheConfig.StaleValuesPolicy.EVICT, conf.caching.getStaleValuesPolicy()); + } + + @Test + public void testPrefixConfigured() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().prefix("prefix"); + assertEquals("prefix", conf.prefix); + } + + @Test + public void testConnectTimeoutConfigured() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().connectTimeout(1, TimeUnit.SECONDS); + assertEquals(1000, conf.connectTimeout); + } + + @Test + public void testSocketTimeoutConfigured() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().socketTimeout(1, TimeUnit.SECONDS); + assertEquals(1000, conf.socketTimeout); + } + + @SuppressWarnings("deprecation") + @Test + public void testCacheTimeWithUnit() throws URISyntaxException { + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().cacheTime(2000, TimeUnit.MILLISECONDS); + assertEquals(2000, conf.caching.getCacheTime()); + assertEquals(TimeUnit.MILLISECONDS, conf.caching.getCacheTimeUnit()); + } + + @Test + public void testPoolConfigConfigured() throws URISyntaxException { + JedisPoolConfig poolConfig = new JedisPoolConfig(); + RedisFeatureStoreBuilder conf = new RedisFeatureStoreBuilder().poolConfig(poolConfig); + assertEquals(poolConfig, conf.poolConfig); + } +} diff --git a/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java similarity index 66% rename from src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java rename to src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java index 08febe5ad..e58d56388 100644 --- a/src/test/java/com/launchdarkly/client/DeprecatedRedisFeatureStoreTest.java +++ b/src/test/java/com/launchdarkly/client/RedisFeatureStoreTest.java @@ -1,5 +1,8 @@ package com.launchdarkly.client; +import com.launchdarkly.client.RedisFeatureStore.UpdateListener; + +import org.junit.Assume; import org.junit.BeforeClass; import java.net.URI; @@ -8,12 +11,11 @@ import redis.clients.jedis.Jedis; -@SuppressWarnings({ "javadoc", "deprecation" }) -public class DeprecatedRedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { +public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - public DeprecatedRedisFeatureStoreTest(boolean cached) { + public RedisFeatureStoreTest(boolean cached) { super(cached); } @@ -41,4 +43,15 @@ protected void clearAllData() { client.flushDB(); } } + + @Override + protected boolean setUpdateHook(RedisFeatureStore storeUnderTest, final Runnable hook) { + storeUnderTest.setUpdateListener(new UpdateListener() { + @Override + public void aboutToUpdate(String baseKey, String itemKey) { + hook.run(); + } + }); + return true; + } } diff --git a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java similarity index 65% rename from src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java rename to src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java index f7c365b41..cc8e344bd 100644 --- a/src/test/java/com/launchdarkly/client/integrations/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.client.files; import com.google.gson.JsonPrimitive; import com.launchdarkly.client.LDClient; @@ -7,23 +7,22 @@ import org.junit.Test; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAG_1_VALUE; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +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; -@SuppressWarnings("javadoc") public class ClientWithFileDataSourceTest { private static final LDUser user = new LDUser.Builder("userkey").build(); private LDClient makeClient() throws Exception { - FileDataSourceBuilder fdsb = FileData.dataSource() + FileDataSourceFactory fdsf = FileComponents.fileDataSource() .filePaths(resourceFilePath("all-properties.json")); LDConfig config = new LDConfig.Builder() - .dataSource(fdsb) + .dataSource(fdsf) .sendEvents(false) .build(); return new LDClient("sdkKey", config); diff --git a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java b/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java similarity index 86% rename from src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java rename to src/test/java/com/launchdarkly/client/files/DataLoaderTest.java index b78585783..9145c7d32 100644 --- a/src/test/java/com/launchdarkly/client/integrations/DataLoaderTest.java +++ b/src/test/java/com/launchdarkly/client/files/DataLoaderTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.client.files; import com.google.common.collect.ImmutableList; import com.google.gson.Gson; @@ -6,9 +6,6 @@ import com.google.gson.JsonObject; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; -import com.launchdarkly.client.integrations.FileDataSourceImpl.DataBuilder; -import com.launchdarkly.client.integrations.FileDataSourceImpl.DataLoader; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; import org.junit.Assert; import org.junit.Test; @@ -17,13 +14,12 @@ import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUE_1_KEY; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +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; -@SuppressWarnings("javadoc") public class DataLoaderTest { private static final Gson gson = new Gson(); private DataBuilder builder = new DataBuilder(); @@ -74,7 +70,7 @@ public void duplicateFlagKeyInFlagsThrowsException() throws Exception { DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), resourceFilePath("flag-with-duplicate-key.json"))); ds.load(builder); - } catch (FileDataException e) { + } catch (DataLoaderException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); } } @@ -85,7 +81,7 @@ public void duplicateFlagKeyInFlagsAndFlagValuesThrowsException() throws Excepti DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("flag-only.json"), resourceFilePath("value-with-duplicate-key.json"))); ds.load(builder); - } catch (FileDataException e) { + } catch (DataLoaderException e) { assertThat(e.getMessage(), containsString("key \"flag1\" was already defined")); } } @@ -96,7 +92,7 @@ public void duplicateSegmentKeyThrowsException() throws Exception { DataLoader ds = new DataLoader(ImmutableList.of(resourceFilePath("segment-only.json"), resourceFilePath("segment-with-duplicate-key.json"))); ds.load(builder); - } catch (FileDataException e) { + } catch (DataLoaderException e) { assertThat(e.getMessage(), containsString("key \"seg1\" was already defined")); } } diff --git a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java similarity index 88% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java rename to src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java index 0d933e967..62924d9d5 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/files/FileDataSourceTest.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.client.files; import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.InMemoryFeatureStore; @@ -14,28 +14,28 @@ import java.nio.file.Paths; import java.util.concurrent.Future; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_FLAG_KEYS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.ALL_SEGMENT_KEYS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.getResourceContents; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +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; -@SuppressWarnings("javadoc") 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 FileDataSourceBuilder factory; + private final FileDataSourceFactory factory; public FileDataSourceTest() throws Exception { factory = makeFactoryWithFile(resourceFilePath("all-properties.json")); } - private static FileDataSourceBuilder makeFactoryWithFile(Path path) { - return FileData.dataSource().filePaths(path); + private static FileDataSourceFactory makeFactoryWithFile(Path path) { + return fileDataSource().filePaths(path); } @Test @@ -94,7 +94,7 @@ public void initializedIsFalseAfterUnsuccessfulLoad() throws Exception { @Test public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { File file = makeTempFlagFile(); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()); + FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()); try { setFileContents(file, getResourceContents("flag-only.json")); try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { @@ -115,7 +115,7 @@ public void modifiedFileIsNotReloadedIfAutoUpdateIsOff() throws Exception { @Test public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { File file = makeTempFlagFile(); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { setFileContents(file, getResourceContents("flag-only.json")); // this file has 1 flag @@ -142,7 +142,7 @@ public void modifiedFileIsReloadedIfAutoUpdateIsOn() throws Exception { public void ifFilesAreBadAtStartTimeAutoUpdateCanStillLoadGoodDataLater() throws Exception { File file = makeTempFlagFile(); setFileContents(file, "not valid"); - FileDataSourceBuilder factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); + FileDataSourceFactory factory1 = makeFactoryWithFile(file.toPath()).autoUpdate(true); long maxMsToWait = 10000; try { try (UpdateProcessor fp = factory1.createUpdateProcessor("", config, store)) { diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java b/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java similarity index 76% rename from src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java rename to src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java index fd2be268f..d6165e279 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserTestBase.java +++ b/src/test/java/com/launchdarkly/client/files/FlagFileParserTestBase.java @@ -1,8 +1,4 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.integrations.FileDataSourceParsing.FileDataException; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileParser; -import com.launchdarkly.client.integrations.FileDataSourceParsing.FlagFileRep; +package com.launchdarkly.client.files; import org.junit.Test; @@ -10,15 +6,14 @@ import java.io.FileNotFoundException; import java.net.URISyntaxException; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FLAG_VALUES; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_FLAGS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.FULL_SEGMENTS; -import static com.launchdarkly.client.integrations.FileDataSourceTestData.resourceFilePath; +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; -@SuppressWarnings("javadoc") public abstract class FlagFileParserTestBase { private final FlagFileParser parser; private final String fileExtension; @@ -68,7 +63,7 @@ public void canParseFileWithOnlySegment() throws Exception { } } - @Test(expected = FileDataException.class) + @Test(expected = DataLoaderException.class) public void throwsExpectedErrorForBadFile() throws Exception { try (FileInputStream input = openFile("malformed")) { parser.parse(input); 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/integrations/FileDataSourceTestData.java b/src/test/java/com/launchdarkly/client/files/TestData.java similarity index 90% rename from src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java rename to src/test/java/com/launchdarkly/client/files/TestData.java index d222f4c77..d1f098d7c 100644 --- a/src/test/java/com/launchdarkly/client/integrations/FileDataSourceTestData.java +++ b/src/test/java/com/launchdarkly/client/files/TestData.java @@ -1,4 +1,4 @@ -package com.launchdarkly.client.integrations; +package com.launchdarkly.client.files; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -14,8 +14,7 @@ import java.util.Map; import java.util.Set; -@SuppressWarnings("javadoc") -public class FileDataSourceTestData { +public class TestData { private static final Gson gson = new Gson(); // These should match the data in our test files @@ -41,11 +40,12 @@ public class FileDataSourceTestData { public static final Set ALL_SEGMENT_KEYS = ImmutableSet.of(FULL_SEGMENT_1_KEY); public static Path resourceFilePath(String filename) throws URISyntaxException { - URL resource = FileDataSourceTestData.class.getClassLoader().getResource("filesource/" + filename); + 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/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java deleted file mode 100644 index c23a66772..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserJsonTest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.integrations.FileDataSourceParsing.JsonFlagFileParser; - -@SuppressWarnings("javadoc") -public class FlagFileParserJsonTest extends FlagFileParserTestBase { - public FlagFileParserJsonTest() { - super(new JsonFlagFileParser(), ".json"); - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java b/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java deleted file mode 100644 index 3ad640e92..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/FlagFileParserYamlTest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.integrations.FileDataSourceParsing.YamlFlagFileParser; - -@SuppressWarnings("javadoc") -public class FlagFileParserYamlTest extends FlagFileParserTestBase { - public FlagFileParserYamlTest() { - super(new YamlFlagFileParser(), ".yml"); - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java deleted file mode 100644 index de8cf2570..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.FeatureStoreCacheConfig; - -import org.junit.Test; - -import java.net.URISyntaxException; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; - -import redis.clients.jedis.JedisPoolConfig; -import redis.clients.jedis.Protocol; - -@SuppressWarnings("javadoc") -public class RedisFeatureStoreBuilderTest { - @Test - public void testDefaultValues() { - RedisDataStoreBuilder conf = Redis.dataStore(); - assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); - assertEquals(FeatureStoreCacheConfig.DEFAULT, conf.caching); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); - assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); - assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); - assertNull(conf.poolConfig); - } - - @Test - public void testPrefixConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().prefix("prefix"); - assertEquals("prefix", conf.prefix); - } - - @Test - public void testConnectTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().connectTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.connectTimeout); - } - - @Test - public void testSocketTimeoutConfigured() throws URISyntaxException { - RedisDataStoreBuilder conf = Redis.dataStore().socketTimeout(1, TimeUnit.SECONDS); - assertEquals(1000, conf.socketTimeout); - } - - @Test - public void testPoolConfigConfigured() throws URISyntaxException { - JedisPoolConfig poolConfig = new JedisPoolConfig(); - RedisDataStoreBuilder conf = Redis.dataStore().poolConfig(poolConfig); - assertEquals(poolConfig, conf.poolConfig); - } -} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java deleted file mode 100644 index 8e4f6ea1b..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.FeatureStoreCacheConfig; -import com.launchdarkly.client.FeatureStoreDatabaseTestBase; -import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; -import com.launchdarkly.client.utils.CachingStoreWrapper; - -import org.junit.BeforeClass; - -import java.net.URI; - -import static org.junit.Assume.assumeTrue; - -import redis.clients.jedis.Jedis; - -@SuppressWarnings("javadoc") -public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { - - private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - - public RedisFeatureStoreTest(boolean cached) { - super(cached); - } - - @BeforeClass - public static void maybeSkipDatabaseTests() { - String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); - assumeTrue(skipParam == null || skipParam.equals("")); - } - - @Override - protected FeatureStore makeStore() { - RedisDataStoreBuilder builder = Redis.dataStore().uri(REDIS_URI); - builder.caching(cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : FeatureStoreCacheConfig.disabled()); - return builder.createFeatureStore(); - } - - @Override - protected FeatureStore makeStoreWithPrefix(String prefix) { - return Redis.dataStore().uri(REDIS_URI).caching(FeatureStoreCacheConfig.disabled()).prefix(prefix).createFeatureStore(); - } - - @Override - protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { - client.flushDB(); - } - } - - @Override - protected boolean setUpdateHook(FeatureStore storeUnderTest, final Runnable hook) { - RedisDataStoreImpl core = (RedisDataStoreImpl)((CachingStoreWrapper)storeUnderTest).getCore(); - core.setUpdateListener(new UpdateListener() { - @Override - public void aboutToUpdate(String baseKey, String itemKey) { - hook.run(); - } - }); - return true; - } -} From fe364f635d1ebcb66e1f865cb722f276bd0f6cfd Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 11:17:21 -0800 Subject: [PATCH 243/327] Revert "Merge pull request #164 from launchdarkly/eb/ch59598/intf-names-4.x" --- .../com/launchdarkly/client/Components.java | 62 +-------------- .../client/InMemoryFeatureStore.java | 2 +- .../com/launchdarkly/client/LDClient.java | 8 +- .../com/launchdarkly/client/LDConfig.java | 76 ++++--------------- .../client/files/FileComponents.java | 2 +- .../client/files/FileDataSourceFactory.java | 2 +- .../client/LDClientEvaluationTest.java | 12 +-- .../client/LDClientEventTest.java | 4 +- .../client/LDClientLddModeTest.java | 2 +- .../client/LDClientOfflineTest.java | 4 +- .../com/launchdarkly/client/LDClientTest.java | 16 ++-- .../client/StreamProcessorTest.java | 2 +- .../files/ClientWithFileDataSourceTest.java | 2 +- 13 files changed, 47 insertions(+), 147 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index e95785915..65c993869 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -17,25 +17,9 @@ public abstract class Components { private static final UpdateProcessorFactory nullUpdateProcessorFactory = new NullUpdateProcessorFactory(); /** - * Returns a factory for the default in-memory implementation of a data store. - * - * Note that the interface is still named {@link FeatureStoreFactory}, but in a future version it - * will be renamed to {@code DataStoreFactory}. - * + * Returns a factory for the default in-memory implementation of {@link FeatureStore}. * @return a factory object - * @since 4.11.0 - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ - public static FeatureStoreFactory inMemoryDataStore() { - return inMemoryFeatureStoreFactory; - } - - /** - * Deprecated name for {@link #inMemoryDataStore()}. - * @return a factory object - * @deprecated Use {@link #inMemoryDataStore()}. - */ - @Deprecated public static FeatureStoreFactory inMemoryFeatureStore() { return inMemoryFeatureStoreFactory; } @@ -44,7 +28,6 @@ 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 - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ public static RedisFeatureStoreBuilder redisFeatureStore() { return new RedisFeatureStoreBuilder(); @@ -55,7 +38,6 @@ public static RedisFeatureStoreBuilder redisFeatureStore() { * specifying the Redis URI. * @param redisUri the URI of the Redis host * @return a factory/builder object - * @see LDConfig.Builder#dataStore(FeatureStoreFactory) */ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { return new RedisFeatureStoreBuilder(redisUri); @@ -66,7 +48,6 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * 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 - * @see LDConfig.Builder#eventProcessorFactory(EventProcessorFactory) */ public static EventProcessorFactory defaultEventProcessor() { return defaultEventProcessorFactory; @@ -76,34 +57,17 @@ 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 - * @see LDConfig.Builder#eventProcessorFactory(EventProcessorFactory) */ public static EventProcessorFactory nullEventProcessor() { return nullEventProcessorFactory; } /** - * Returns a factory for the default implementation of the component for receiving feature flag data - * from LaunchDarkly. Based on your configuration, this implementation uses either streaming or - * polling, or does nothing if the client is offline, or in LDD mode. - * - * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}. - * - * @return a factory object - * @since 4.11.0 - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - */ - public static UpdateProcessorFactory defaultDataSource() { - return defaultUpdateProcessorFactory; - } - - /** - * Deprecated name for {@link #defaultDataSource()}. + * 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 - * @deprecated Use {@link #defaultDataSource()}. */ - @Deprecated public static UpdateProcessorFactory defaultUpdateProcessor() { return defaultUpdateProcessorFactory; } @@ -111,24 +75,8 @@ 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. - * - * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}. - * - * @return a factory object - * @since 4.11.0 - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) - */ - public static UpdateProcessorFactory nullDataSource() { - return nullUpdateProcessorFactory; - } - - /** - * Deprecated name for {@link #nullDataSource()}. * @return a factory object - * @deprecated Use {@link #nullDataSource()}. */ - @Deprecated public static UpdateProcessorFactory nullUpdateProcessor() { return nullUpdateProcessorFactory; } @@ -161,7 +109,6 @@ private static final class DefaultUpdateProcessorFactory implements UpdateProces // Note, logger uses LDClient class name for backward compatibility private static final Logger logger = LoggerFactory.getLogger(LDClient.class); - @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { if (config.offline) { @@ -185,7 +132,6 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea } private static final class NullUpdateProcessorFactory implements UpdateProcessorFactory { - @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { return new UpdateProcessor.NullUpdateProcessor(); diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index 05ad4bbb2..b8db96e3a 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -9,7 +9,7 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; /** - * A thread-safe, versioned store for feature flags and related data based on a + * A thread-safe, versioned store for {@link FeatureFlag} objects and related data based on a * {@link HashMap}. This is the default implementation of {@link FeatureStore}. */ public class InMemoryFeatureStore implements FeatureStore { diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 676c518ed..03abbee3c 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -72,8 +72,8 @@ public LDClient(String sdkKey, LDConfig config) { // of instances that we created ourselves from a factory. this.shouldCloseFeatureStore = false; } else { - FeatureStoreFactory factory = config.dataStoreFactory == null ? - Components.inMemoryDataStore() : config.dataStoreFactory; + FeatureStoreFactory factory = config.featureStoreFactory == null ? + Components.inMemoryFeatureStore() : config.featureStoreFactory; store = factory.createFeatureStore(); this.shouldCloseFeatureStore = true; } @@ -83,8 +83,8 @@ public LDClient(String sdkKey, LDConfig config) { Components.defaultEventProcessor() : config.eventProcessorFactory; this.eventProcessor = epFactory.createEventProcessor(sdkKey, config); - UpdateProcessorFactory upFactory = config.dataSourceFactory == null ? - Components.defaultDataSource() : config.dataSourceFactory; + UpdateProcessorFactory upFactory = config.updateProcessorFactory == null ? + Components.defaultUpdateProcessor() : config.updateProcessorFactory; this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, config, featureStore); Future startFuture = updateProcessor.start(); if (config.startWaitMillis > 0L) { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 00db0bbe0..38ff7b475 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -56,9 +56,9 @@ public final class LDConfig { final Authenticator proxyAuthenticator; final boolean stream; final FeatureStore deprecatedFeatureStore; - final FeatureStoreFactory dataStoreFactory; + final FeatureStoreFactory featureStoreFactory; final EventProcessorFactory eventProcessorFactory; - final UpdateProcessorFactory dataSourceFactory; + final UpdateProcessorFactory updateProcessorFactory; final boolean useLdd; final boolean offline; final boolean allAttributesPrivate; @@ -88,9 +88,9 @@ protected LDConfig(Builder builder) { this.streamURI = builder.streamURI; this.stream = builder.stream; this.deprecatedFeatureStore = builder.featureStore; - this.dataStoreFactory = builder.dataStoreFactory; + this.featureStoreFactory = builder.featureStoreFactory; this.eventProcessorFactory = builder.eventProcessorFactory; - this.dataSourceFactory = builder.dataSourceFactory; + this.updateProcessorFactory = builder.updateProcessorFactory; this.useLdd = builder.useLdd; this.offline = builder.offline; this.allAttributesPrivate = builder.allAttributesPrivate; @@ -155,9 +155,9 @@ public static class Builder { private boolean sendEvents = true; private long pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; private FeatureStore featureStore = null; - private FeatureStoreFactory dataStoreFactory = Components.inMemoryDataStore(); + private FeatureStoreFactory featureStoreFactory = Components.inMemoryFeatureStore(); private EventProcessorFactory eventProcessorFactory = Components.defaultEventProcessor(); - private UpdateProcessorFactory dataSourceFactory = Components.defaultDataSource(); + private UpdateProcessorFactory updateProcessorFactory = Components.defaultUpdateProcessor(); private long startWaitMillis = DEFAULT_START_WAIT_MILLIS; private int samplingInterval = DEFAULT_SAMPLING_INTERVAL; private long reconnectTimeMillis = DEFAULT_RECONNECT_TIME_MILLIS; @@ -207,24 +207,6 @@ public Builder streamURI(URI streamURI) { return this; } - /** - * Sets the implementation of the data store to be used for holding feature flags and - * related data received from LaunchDarkly, using a factory object. The default is - * {@link Components#inMemoryDataStore()}, but you may use {@link Components#redisFeatureStore()} - * or a custom implementation. - * - * Note that the interface is still called {@link FeatureStoreFactory}, but in a future version - * it will be renamed to {@code DataStoreFactory}. - * - * @param factory the factory object - * @return the builder - * @since 4.11.0 - */ - public Builder dataStore(FeatureStoreFactory factory) { - this.dataStoreFactory = factory; - return this; - } - /** * Sets the implementation of {@link FeatureStore} to be used for holding feature flags and * related data received from LaunchDarkly. The default is {@link InMemoryFeatureStore}, but @@ -239,37 +221,26 @@ public Builder featureStore(FeatureStore store) { } /** - * Deprecated name for {@link #dataStore(FeatureStoreFactory)}. + * Sets the implementation of {@link FeatureStore} to be used for holding feature flags and + * related data received from LaunchDarkly, using a factory object. The default is + * {@link Components#inMemoryFeatureStore()}, but you may use {@link Components#redisFeatureStore()} + * or a custom implementation. * @param factory the factory object * @return the builder * @since 4.0.0 - * @deprecated Use {@link #dataStore(FeatureStoreFactory)}. */ - @Deprecated public Builder featureStoreFactory(FeatureStoreFactory factory) { - this.dataStoreFactory = factory; + this.featureStoreFactory = factory; return this; } - + /** * Sets the implementation of {@link EventProcessor} to be used for processing analytics events, * using a factory object. The default is {@link Components#defaultEventProcessor()}, but * you may choose to use a custom implementation (for instance, a test fixture). * @param factory the factory object * @return the builder - * @since 4.11.0 - */ - public Builder eventProcessor(EventProcessorFactory factory) { - this.eventProcessorFactory = factory; - return this; - } - - /** - * Deprecated name for {@link #eventProcessor(EventProcessorFactory)}. - * @param factory the factory object - * @return the builder * @since 4.0.0 - * @deprecated Use {@link #eventProcessor(EventProcessorFactory)}. */ public Builder eventProcessorFactory(EventProcessorFactory factory) { this.eventProcessorFactory = factory; @@ -277,32 +248,15 @@ public Builder eventProcessorFactory(EventProcessorFactory factory) { } /** - * Sets the implementation of the component that receives feature flag data from LaunchDarkly, - * using a factory object. The default is {@link Components#defaultDataSource()}, but + * Sets the implementation of {@link UpdateProcessor} to be used for receiving feature flag data, + * using a factory object. The default is {@link Components#defaultUpdateProcessor()}, but * you may choose to use a custom implementation (for instance, a test fixture). - * - * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version - * it will be renamed to {@code DataSourceFactory}. - * - * @param factory the factory object - * @return the builder - * @since 4.11.0 - */ - public Builder dataSource(UpdateProcessorFactory factory) { - this.dataSourceFactory = factory; - return this; - } - - /** - * Deprecated name for {@link #dataSource(UpdateProcessorFactory)}. * @param factory the factory object * @return the builder * @since 4.0.0 - * @deprecated Use {@link #dataSource(UpdateProcessorFactory)}. */ - @Deprecated public Builder updateProcessorFactory(UpdateProcessorFactory factory) { - this.dataSourceFactory = factory; + this.updateProcessorFactory = factory; return this; } diff --git a/src/main/java/com/launchdarkly/client/files/FileComponents.java b/src/main/java/com/launchdarkly/client/files/FileComponents.java index f893b7f47..390fb75a3 100644 --- a/src/main/java/com/launchdarkly/client/files/FileComponents.java +++ b/src/main/java/com/launchdarkly/client/files/FileComponents.java @@ -16,7 +16,7 @@ * .filePaths("./testData/flags.json") * .autoUpdate(true); * LDConfig config = new LDConfig.Builder() - * .dataSource(f) + * .updateProcessorFactory(f) * .build(); * *

    diff --git a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java index 8f0d36cf0..216c56213 100644 --- a/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java +++ b/src/main/java/com/launchdarkly/client/files/FileDataSourceFactory.java @@ -14,7 +14,7 @@ /** * 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#dataSource(UpdateProcessorFactory)}. + * then pass the resulting object to {@link com.launchdarkly.client.LDConfig.Builder#updateProcessorFactory(UpdateProcessorFactory)}. *

    * For more details, see {@link FileComponents}. * diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 783eadd1a..334d19ad5 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -34,9 +34,9 @@ public class LDClientEvaluationTest { private FeatureStore featureStore = TestUtil.initedFeatureStore(); private LDConfig config = new LDConfig.Builder() - .dataStore(specificFeatureStore(featureStore)) + .featureStoreFactory(specificFeatureStore(featureStore)) .eventProcessorFactory(Components.nullEventProcessor()) - .dataSource(Components.nullUpdateProcessor()) + .updateProcessorFactory(Components.nullUpdateProcessor()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); @@ -222,9 +222,9 @@ public void variationDetailReturnsDefaultIfFlagEvaluatesToNull() { public void appropriateErrorIfClientNotInitialized() throws Exception { FeatureStore badFeatureStore = new InMemoryFeatureStore(); LDConfig badConfig = new LDConfig.Builder() - .dataStore(specificFeatureStore(badFeatureStore)) + .featureStoreFactory(specificFeatureStore(badFeatureStore)) .eventProcessorFactory(Components.nullEventProcessor()) - .dataSource(specificUpdateProcessor(failedUpdateProcessor())) + .updateProcessorFactory(specificUpdateProcessor(failedUpdateProcessor())) .startWaitMillis(0) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { @@ -264,9 +264,9 @@ public void appropriateErrorForUnexpectedException() throws Exception { RuntimeException exception = new RuntimeException("sorry"); FeatureStore badFeatureStore = featureStoreThatThrowsException(exception); LDConfig badConfig = new LDConfig.Builder() - .dataStore(specificFeatureStore(badFeatureStore)) + .featureStoreFactory(specificFeatureStore(badFeatureStore)) .eventProcessorFactory(Components.nullEventProcessor()) - .dataSource(Components.nullUpdateProcessor()) + .updateProcessorFactory(Components.nullUpdateProcessor()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 941c2615d..f71a56bf3 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -29,9 +29,9 @@ public class LDClientEventTest { private FeatureStore featureStore = TestUtil.initedFeatureStore(); private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() - .dataStore(specificFeatureStore(featureStore)) + .featureStoreFactory(specificFeatureStore(featureStore)) .eventProcessorFactory(specificEventProcessor(eventSink)) - .dataSource(Components.nullUpdateProcessor()) + .updateProcessorFactory(Components.nullUpdateProcessor()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java index afc4ca6c5..76ee3611a 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java @@ -50,7 +50,7 @@ public void lddModeClientGetsFlagFromFeatureStore() throws IOException { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .useLdd(true) - .dataStore(specificFeatureStore(testFeatureStore)) + .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); FeatureFlag flag = flagWithValue("key", LDValue.of(true)); testFeatureStore.upsert(FEATURES, flag); diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 45aa48bfa..cff7ed994 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -66,7 +66,7 @@ public void offlineClientGetsAllFlagsFromFeatureStore() throws IOException { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .dataStore(specificFeatureStore(testFeatureStore)) + .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -80,7 +80,7 @@ public void offlineClientGetsFlagsStateFromFeatureStore() throws IOException { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig config = new LDConfig.Builder() .offline(true) - .dataStore(specificFeatureStore(testFeatureStore)) + .featureStoreFactory(specificFeatureStore(testFeatureStore)) .build(); testFeatureStore.upsert(FEATURES, flagWithValue("key", LDValue.of(true))); try (LDClient client = new LDClient("SDK_KEY", config)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 02dc39657..b585536c9 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -201,7 +201,7 @@ public void isFlagKnownReturnsTrueForExistingFlag() throws Exception { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); + .featureStoreFactory(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(true).times(1); replayAll(); @@ -218,7 +218,7 @@ public void isFlagKnownReturnsFalseForUnknownFlag() throws Exception { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); + .featureStoreFactory(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(true).times(1); replayAll(); @@ -234,7 +234,7 @@ public void isFlagKnownReturnsFalseIfStoreAndClientAreNotInitialized() throws Ex FeatureStore testFeatureStore = new InMemoryFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); + .featureStoreFactory(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(false).times(1); replayAll(); @@ -251,7 +251,7 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() .startWaitMillis(0) - .dataStore(specificFeatureStore(testFeatureStore)); + .featureStoreFactory(specificFeatureStore(testFeatureStore)); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(false).times(1); replayAll(); @@ -267,7 +267,7 @@ public void isFlagKnownUsesStoreIfStoreIsInitializedButClientIsNot() throws Exce public void evaluationUsesStoreIfStoreIsInitializedButClientIsNot() throws Exception { FeatureStore testFeatureStore = initedFeatureStore(); LDConfig.Builder config = new LDConfig.Builder() - .dataStore(specificFeatureStore(testFeatureStore)) + .featureStoreFactory(specificFeatureStore(testFeatureStore)) .startWaitMillis(0L); expect(updateProcessor.start()).andReturn(initFuture); expect(updateProcessor.initialized()).andReturn(false); @@ -294,8 +294,8 @@ public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { replay(store); LDConfig.Builder config = new LDConfig.Builder() - .dataSource(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) - .dataStore(specificFeatureStore(store)) + .updateProcessorFactory(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) + .featureStoreFactory(specificFeatureStore(store)) .sendEvents(false); client = new LDClient("SDK_KEY", config.build()); @@ -340,7 +340,7 @@ private void expectEventsSent(int count) { } private LDClientInterface createMockClient(LDConfig.Builder config) { - config.dataSource(TestUtil.specificUpdateProcessor(updateProcessor)); + config.updateProcessorFactory(TestUtil.specificUpdateProcessor(updateProcessor)); config.eventProcessorFactory(TestUtil.specificEventProcessor(eventProcessor)); return new LDClient("SDK_KEY", config.build()); } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index ff49176c3..92a45136b 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -65,7 +65,7 @@ public class StreamProcessorTest extends EasyMockSupport { @Before public void setup() { featureStore = new InMemoryFeatureStore(); - configBuilder = new LDConfig.Builder().dataStore(specificFeatureStore(featureStore)); + configBuilder = new LDConfig.Builder().featureStoreFactory(specificFeatureStore(featureStore)); mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createStrictMock(EventSource.class); } diff --git a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java index cc8e344bd..e8ec26040 100644 --- a/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java +++ b/src/test/java/com/launchdarkly/client/files/ClientWithFileDataSourceTest.java @@ -22,7 +22,7 @@ private LDClient makeClient() throws Exception { FileDataSourceFactory fdsf = FileComponents.fileDataSource() .filePaths(resourceFilePath("all-properties.json")); LDConfig config = new LDConfig.Builder() - .dataSource(fdsf) + .updateProcessorFactory(fdsf) .sendEvents(false) .build(); return new LDClient("sdkKey", config); From 3123f03c0f9a7d9e1804cdb82df4412a303bb289 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 11:51:15 -0800 Subject: [PATCH 244/327] rm inapplicable comment --- .../com/launchdarkly/client/FeatureStoreCacheConfig.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index baa1e3942..a956b1472 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -164,12 +164,6 @@ public StaleValuesPolicy getStaleValuesPolicy() { * after this amount of time from the time when they were originally cached. *

    * If the value is zero, caching is disabled. - *

    - * If the value is negative, data is cached forever (i.e. it will only be read again from the database - * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario - * where multiple processes are sharing the database, and the current process loses connectivity to - * LaunchDarkly while other processes are still receiving updates and writing them to the database, - * the current process will have stale data. * * @param cacheTime the cache TTL in whatever units you wish * @param timeUnit the time unit From c899d5202c097152524dfd0c22eec789bf596e65 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 12:59:26 -0800 Subject: [PATCH 245/327] Revert "Revert "Merge pull request #167 from launchdarkly/eb/ch51690/infinite-ttl"" This reverts commit 2567c8a72cb123a4e64971fa21dc5e9bb807ee8e. --- .../client/FeatureStoreCacheConfig.java | 32 ++- .../client/utils/CachingStoreWrapper.java | 113 ++++++--- .../client/FeatureStoreCachingTest.java | 9 +- .../client/utils/CachingStoreWrapperTest.java | 219 ++++++++++++++---- 4 files changed, 295 insertions(+), 78 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index a956b1472..6b2fbaabb 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -118,14 +118,31 @@ private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleVal /** * Returns true if caching will be enabled. - * @return true if the cache TTL is greater than 0 + * @return true if the cache TTL is non-zero */ public boolean isEnabled() { - return getCacheTime() > 0; + return getCacheTime() != 0; } /** - * Returns the cache TTL. Caching is enabled if this is greater than zero. + * Returns true if caching is enabled and does not have a finite TTL. + * @return true if the cache TTL is negative + */ + public boolean isInfiniteTtl() { + return getCacheTime() < 0; + } + + /** + * Returns the cache TTL. + *

    + * If the value is zero, caching is disabled. + *

    + * If the value is negative, data is cached forever (i.e. it will only be read again from the database + * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario + * where multiple processes are sharing the database, and the current process loses connectivity to + * LaunchDarkly while other processes are still receiving updates and writing them to the database, + * the current process will have stale data. + * * @return the cache TTL in whatever units were specified * @see #getCacheTimeUnit() */ @@ -143,6 +160,15 @@ public TimeUnit getCacheTimeUnit() { /** * Returns the cache TTL converted to milliseconds. + *

    + * If the value is zero, caching is disabled. + *

    + * If the value is negative, data is cached forever (i.e. it will only be read again from the database + * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario + * where multiple processes are sharing the database, and the current process loses connectivity to + * LaunchDarkly while other processes are still receiving updates and writing them to the database, + * the current process will have stale data. + * * @return the TTL in milliseconds */ public long getCacheTimeMillis() { diff --git a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java index e2e5fa144..47de79688 100644 --- a/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java +++ b/src/main/java/com/launchdarkly/client/utils/CachingStoreWrapper.java @@ -5,6 +5,7 @@ import com.google.common.cache.CacheLoader; import com.google.common.cache.CacheStats; import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableMap; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; @@ -35,8 +36,9 @@ 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 FeatureStoreCacheConfig caching; private final LoadingCache> itemCache; - private final LoadingCache, Map> allCache; + private final LoadingCache, ImmutableMap> allCache; private final LoadingCache initCache; private final AtomicBoolean inited = new AtomicBoolean(false); private final ListeningExecutorService executorService; @@ -52,6 +54,7 @@ public static CachingStoreWrapper.Builder builder(FeatureStoreCore core) { protected CachingStoreWrapper(final FeatureStoreCore core, FeatureStoreCacheConfig caching) { this.core = core; + this.caching = caching; if (!caching.isEnabled()) { itemCache = null; @@ -65,9 +68,9 @@ public Optional load(CacheKey key) throws Exception { return Optional.fromNullable(core.getInternal(key.kind, key.key)); } }; - CacheLoader, Map> allLoader = new CacheLoader, Map>() { + CacheLoader, ImmutableMap> allLoader = new CacheLoader, ImmutableMap>() { @Override - public Map load(VersionedDataKind kind) throws Exception { + public ImmutableMap load(VersionedDataKind kind) throws Exception { return itemsOnlyIfNotDeleted(core.getAllInternal(kind)); } }; @@ -78,17 +81,18 @@ public Boolean load(String key) throws Exception { } }; - switch (caching.getStaleValuesPolicy()) { - case EVICT: + if (caching.isInfiniteTtl()) { + itemCache = CacheBuilder.newBuilder().build(itemLoader); + allCache = CacheBuilder.newBuilder().build(allLoader); + executorService = null; + } else if (caching.getStaleValuesPolicy() == FeatureStoreCacheConfig.StaleValuesPolicy.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: + } else { // 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, @@ -105,7 +109,11 @@ public Boolean load(String key) throws Exception { allCache = CacheBuilder.newBuilder().refreshAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(allLoader); } - initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); + if (caching.isInfiniteTtl()) { + initCache = CacheBuilder.newBuilder().build(initLoader); + } else { + initCache = CacheBuilder.newBuilder().expireAfterWrite(caching.getCacheTime(), caching.getCacheTimeUnit()).build(initLoader); + } } } @@ -146,19 +154,34 @@ 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); + try { + core.initInternal(castMap); + } catch (RuntimeException e) { + // Normally, if the underlying store failed to do the update, we do not want to update the cache - + // the idea being that it's better to stay in a consistent state of having old data than to act + // like we have new data but then suddenly fall back to old data when the cache expires. However, + // if the cache TTL is infinite, then it makes sense to update the cache always. + if (allCache != null && itemCache != null && caching.isInfiniteTtl()) { + updateAllCache(castMap); + inited.set(true); + } + throw e; + } - inited.set(true); - if (allCache != null && itemCache != null) { allCache.invalidateAll(); itemCache.invalidateAll(); - for (Map.Entry, Map> e0: castMap.entrySet()) { - VersionedDataKind kind = e0.getKey(); - allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); - for (Map.Entry e1: e0.getValue().entrySet()) { - itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); - } + updateAllCache(castMap); + } + inited.set(true); + } + + private void updateAllCache(Map, Map> allData) { + for (Map.Entry, Map> e0: allData.entrySet()) { + VersionedDataKind kind = e0.getKey(); + allCache.put(kind, itemsOnlyIfNotDeleted(e0.getValue())); + for (Map.Entry e1: e0.getValue().entrySet()) { + itemCache.put(CacheKey.forItem(kind, e1.getKey()), Optional.of(e1.getValue())); } } } @@ -170,15 +193,49 @@ public void delete(VersionedDataKind kind, String k @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)); + VersionedData newState = item; + RuntimeException failure = null; + try { + newState = core.upsertInternal(kind, item); + } catch (RuntimeException e) { + failure = e; } - if (allCache != null) { - allCache.invalidate(kind); + // Normally, if the underlying store failed to do the update, we do not want to update the cache - + // the idea being that it's better to stay in a consistent state of having old data than to act + // like we have new data but then suddenly fall back to old data when the cache expires. However, + // if the cache TTL is infinite, then it makes sense to update the cache always. + if (failure == null || caching.isInfiniteTtl()) { + if (itemCache != null) { + itemCache.put(CacheKey.forItem(kind, item.getKey()), Optional.fromNullable(newState)); + } + if (allCache != null) { + // If the cache has a finite TTL, then we should remove the "all items" cache entry to force + // a reread the next time All is called. However, if it's an infinite TTL, we need to just + // update the item within the existing "all items" entry (since we want things to still work + // even if the underlying store is unavailable). + if (caching.isInfiniteTtl()) { + try { + ImmutableMap cachedAll = allCache.get(kind); + Map newValues = new HashMap<>(); + newValues.putAll(cachedAll); + newValues.put(item.getKey(), newState); + allCache.put(kind, ImmutableMap.copyOf(newValues)); + } catch (Exception e) { + // An exception here means that we did not have a cached value for All, so it tried to query + // the underlying store, which failed (not surprisingly since it just failed a moment ago + // when we tried to do an update). This should not happen in infinite-cache mode, but if it + // does happen, there isn't really anything we can do. + } + } else { + allCache.invalidate(kind); + } + } + } + if (failure != null) { + throw failure; } } - + @Override public boolean initialized() { if (inited.get()) { @@ -222,16 +279,16 @@ private VersionedData itemOnlyIfNotDeleted(VersionedData item) { } @SuppressWarnings("unchecked") - private Map itemsOnlyIfNotDeleted(Map items) { - Map ret = new HashMap<>(); + private ImmutableMap itemsOnlyIfNotDeleted(Map items) { + ImmutableMap.Builder builder = ImmutableMap.builder(); if (items != null) { for (Map.Entry item: items.entrySet()) { if (!item.getValue().isDeleted()) { - ret.put(item.getKey(), (T) item.getValue()); + builder.put(item.getKey(), (T) item.getValue()); } } } - return ret; + return builder.build(); } private static class CacheKey { diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java index f8d15f517..c9259b622 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreCachingTest.java @@ -10,12 +10,14 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; +@SuppressWarnings("javadoc") public class FeatureStoreCachingTest { @Test public void disabledHasExpectedProperties() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.disabled(); assertThat(fsc.getCacheTime(), equalTo(0L)); assertThat(fsc.isEnabled(), equalTo(false)); + assertThat(fsc.isInfiniteTtl(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -25,6 +27,7 @@ public void enabledHasExpectedProperties() { assertThat(fsc.getCacheTime(), equalTo(FeatureStoreCacheConfig.DEFAULT_TIME_SECONDS)); assertThat(fsc.getCacheTimeUnit(), equalTo(TimeUnit.SECONDS)); assertThat(fsc.isEnabled(), equalTo(true)); + assertThat(fsc.isInfiniteTtl(), equalTo(false)); assertThat(fsc.getStaleValuesPolicy(), equalTo(EVICT)); } @@ -72,13 +75,15 @@ public void zeroTtlMeansDisabled() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(0, TimeUnit.SECONDS); assertThat(fsc.isEnabled(), equalTo(false)); + assertThat(fsc.isInfiniteTtl(), equalTo(false)); } @Test - public void negativeTtlMeansDisabled() { + public void negativeTtlMeansEnabledAndInfinite() { FeatureStoreCacheConfig fsc = FeatureStoreCacheConfig.enabled() .ttl(-1, TimeUnit.SECONDS); - assertThat(fsc.isEnabled(), equalTo(false)); + assertThat(fsc.isEnabled(), equalTo(true)); + assertThat(fsc.isInfiniteTtl(), equalTo(true)); } @Test diff --git a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java index ff9a75d6a..6419b6db1 100644 --- a/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java +++ b/src/test/java/com/launchdarkly/client/utils/CachingStoreWrapperTest.java @@ -5,6 +5,7 @@ import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; @@ -22,23 +23,46 @@ import static org.hamcrest.Matchers.nullValue; import static org.junit.Assume.assumeThat; +@SuppressWarnings("javadoc") @RunWith(Parameterized.class) public class CachingStoreWrapperTest { - private final boolean cached; + private final RuntimeException FAKE_ERROR = new RuntimeException("fake error"); + + private final CachingMode cachingMode; private final MockCore core; private final CachingStoreWrapper wrapper; + static enum CachingMode { + UNCACHED, + CACHED_WITH_FINITE_TTL, + CACHED_INDEFINITELY; + + FeatureStoreCacheConfig toCacheConfig() { + switch (this) { + case CACHED_WITH_FINITE_TTL: + return FeatureStoreCacheConfig.enabled().ttlSeconds(30); + case CACHED_INDEFINITELY: + return FeatureStoreCacheConfig.enabled().ttlSeconds(-1); + default: + return FeatureStoreCacheConfig.disabled(); + } + } + + boolean isCached() { + return this != UNCACHED; + } + }; + @Parameters(name="cached={0}") - public static Iterable data() { - return Arrays.asList(new Boolean[] { false, true }); + public static Iterable data() { + return Arrays.asList(CachingMode.values()); } - public CachingStoreWrapperTest(boolean cached) { - this.cached = cached; + public CachingStoreWrapperTest(CachingMode cachingMode) { + this.cachingMode = cachingMode; this.core = new MockCore(); - this.wrapper = new CachingStoreWrapper(core, cached ? FeatureStoreCacheConfig.enabled().ttlSeconds(30) : - FeatureStoreCacheConfig.disabled()); + this.wrapper = new CachingStoreWrapper(core, cachingMode.toCacheConfig()); } @Test @@ -51,7 +75,7 @@ public void get() { 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 + assertThat(result, equalTo(cachingMode.isCached() ? itemv1 : itemv2)); // if cached, we will not see the new underlying value yet } @Test @@ -64,7 +88,7 @@ public void getDeletedItem() { 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 + assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv2)); // if cached, we will not see the new underlying value yet } @Test @@ -75,14 +99,12 @@ public void getMissingItem() { 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 + assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(item)); // the cache can retain a null result } @Test public void cachedGetUsesValuesFromInit() { - if (!cached) { - return; - } + assumeThat(cachingMode.isCached(), is(true)); MockItem item1 = new MockItem("flag1", 1, false); MockItem item2 = new MockItem("flag2", 1, false); @@ -107,7 +129,7 @@ public void getAll() { core.forceRemove(THINGS, item2.key); items = wrapper.all(THINGS); - if (cached) { + if (cachingMode.isCached()) { assertThat(items, equalTo(expected)); } else { Map expected1 = ImmutableMap.of(item1.key, item1); @@ -129,9 +151,7 @@ public void getAllRemovesDeletedItems() { @Test public void cachedAllUsesValuesFromInit() { - if (!cached) { - return; - } + assumeThat(cachingMode.isCached(), is(true)); MockItem item1 = new MockItem("flag1", 1, false); MockItem item2 = new MockItem("flag2", 1, false); @@ -144,33 +164,44 @@ public void cachedAllUsesValuesFromInit() { Map expected = ImmutableMap.of(item1.key, item1, item2.key, item2); assertThat(items, equalTo(expected)); } - + @Test - public void cachedAllUsesFreshValuesIfThereHasBeenAnUpdate() { - if (!cached) { - return; - } + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreInitFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); - 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); + MockItem item = new MockItem("flag", 1, false); - Map, Map> allData = makeData(item1, item2); - wrapper.init(allData); + core.fakeError = FAKE_ERROR; + try { + wrapper.init(makeData(item)); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(wrapper.all(THINGS).size(), equalTo(0)); + } - // make a change to item1 via the wrapper - this should flush the cache - wrapper.upsert(THINGS, item1v2); + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreInitFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); - // make a change to item2 that bypasses the cache - core.forceSet(THINGS, item2v2); + MockItem item = new MockItem("flag", 1, false); - // 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)); + core.fakeError = FAKE_ERROR; + try { + wrapper.init(makeData(item)); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + Map expected = ImmutableMap.of(item.key, item); + assertThat(wrapper.all(THINGS), equalTo(expected)); } - + @Test public void upsertSuccessful() { MockItem itemv1 = new MockItem("flag", 1, false); @@ -184,7 +215,7 @@ public void upsertSuccessful() { // 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) { + if (cachingMode.isCached()) { MockItem item1v3 = new MockItem("flag", 3, false); core.forceSet(THINGS, item1v3); } @@ -194,9 +225,7 @@ public void upsertSuccessful() { @Test public void cachedUpsertUnsuccessful() { - if (!cached) { - return; - } + assumeThat(cachingMode.isCached(), is(true)); // 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 @@ -217,6 +246,94 @@ public void cachedUpsertUnsuccessful() { assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); } + @Test + public void cachedStoreWithFiniteTtlDoesNotUpdateCacheIfCoreUpdateFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); + + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, false); + + wrapper.init(makeData(itemv1)); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(THINGS, itemv2); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv1)); // cache still has old item, same as underlying store + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCacheEvenIfCoreUpdateFails() { + assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); + + MockItem itemv1 = new MockItem("flag", 1, false); + MockItem itemv2 = new MockItem("flag", 2, false); + + wrapper.init(makeData(itemv1)); + + core.fakeError = FAKE_ERROR; + try { + wrapper.upsert(THINGS, itemv2); + Assert.fail("expected exception"); + } catch(RuntimeException e) { + assertThat(e, is(FAKE_ERROR)); + } + + core.fakeError = null; + assertThat(wrapper.get(THINGS, itemv1.key), equalTo(itemv2)); // underlying store has old item but cache has new item + } + + @Test + public void cachedStoreWithFiniteTtlRemovesCachedAllDataIfOneItemIsUpdated() { + assumeThat(cachingMode, is(CachingMode.CACHED_WITH_FINITE_TTL)); + + MockItem item1v1 = new MockItem("item1", 1, false); + MockItem item1v2 = new MockItem("item1", 2, false); + MockItem item2v1 = new MockItem("item2", 1, false); + MockItem item2v2 = new MockItem("item2", 2, false); + + wrapper.init(makeData(item1v1, item2v1)); + wrapper.all(THINGS); // now the All data is cached + + // do an upsert for item1 - this should drop the previous all() data from the cache + wrapper.upsert(THINGS, item1v2); + + // modify item2 directly in the underlying data + core.forceSet(THINGS, item2v2); + + // now, all() should reread the underlying data so we see both changes + Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v2); + assertThat(wrapper.all(THINGS), equalTo(expected)); + } + + @Test + public void cachedStoreWithInfiniteTtlUpdatesCachedAllDataIfOneItemIsUpdated() { + assumeThat(cachingMode, is(CachingMode.CACHED_INDEFINITELY)); + + MockItem item1v1 = new MockItem("item1", 1, false); + MockItem item1v2 = new MockItem("item1", 2, false); + MockItem item2v1 = new MockItem("item2", 1, false); + MockItem item2v2 = new MockItem("item2", 2, false); + + wrapper.init(makeData(item1v1, item2v1)); + wrapper.all(THINGS); // now the All data is cached + + // do an upsert for item1 - this should update the underlying data *and* the cached all() data + wrapper.upsert(THINGS, item1v2); + + // modify item2 directly in the underlying data + core.forceSet(THINGS, item2v2); + + // now, all() should *not* reread the underlying data - we should only see the change to item1 + Map expected = ImmutableMap.of(item1v1.key, item1v2, item2v1.key, item2v1); + assertThat(wrapper.all(THINGS), equalTo(expected)); + } + @Test public void delete() { MockItem itemv1 = new MockItem("flag", 1, false); @@ -234,12 +351,12 @@ public void delete() { core.forceSet(THINGS, itemv3); MockItem result = wrapper.get(THINGS, itemv1.key); - assertThat(result, cached ? nullValue(MockItem.class) : equalTo(itemv3)); + assertThat(result, cachingMode.isCached() ? nullValue(MockItem.class) : equalTo(itemv3)); } @Test public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { - assumeThat(cached, is(false)); + assumeThat(cachingMode.isCached(), is(false)); assertThat(wrapper.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -255,7 +372,7 @@ public void initializedCallsInternalMethodOnlyIfNotAlreadyInited() { @Test public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { - assumeThat(cached, is(false)); + assumeThat(cachingMode.isCached(), is(false)); assertThat(wrapper.initialized(), is(false)); assertThat(core.initedQueryCount, equalTo(1)); @@ -268,7 +385,7 @@ public void initializedDoesNotCallInternalMethodAfterInitHasBeenCalled() { @Test public void initializedCanCacheFalseResult() throws Exception { - assumeThat(cached, is(true)); + assumeThat(cachingMode.isCached(), 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, FeatureStoreCacheConfig.enabled().ttlMillis(500))) { @@ -303,6 +420,7 @@ static class MockCore implements FeatureStoreCore { Map, Map> data = new HashMap<>(); boolean inited; int initedQueryCount; + RuntimeException fakeError; @Override public void close() throws IOException { @@ -310,6 +428,7 @@ public void close() throws IOException { @Override public VersionedData getInternal(VersionedDataKind kind, String key) { + maybeThrow(); if (data.containsKey(kind)) { return data.get(kind).get(key); } @@ -318,11 +437,13 @@ public VersionedData getInternal(VersionedDataKind kind, String key) { @Override public Map getAllInternal(VersionedDataKind kind) { + maybeThrow(); return data.get(kind); } @Override public void initInternal(Map, Map> allData) { + maybeThrow(); data.clear(); for (Map.Entry, Map> entry: allData.entrySet()) { data.put(entry.getKey(), new LinkedHashMap(entry.getValue())); @@ -332,6 +453,7 @@ public void initInternal(Map, Map> a @Override public VersionedData upsertInternal(VersionedDataKind kind, VersionedData item) { + maybeThrow(); if (!data.containsKey(kind)) { data.put(kind, new HashMap()); } @@ -346,6 +468,7 @@ public VersionedData upsertInternal(VersionedDataKind kind, VersionedData ite @Override public boolean initializedInternal() { + maybeThrow(); initedQueryCount++; return inited; } @@ -363,6 +486,12 @@ public void forceRemove(VersionedDataKind kind, String key) { data.get(kind).remove(key); } } + + private void maybeThrow() { + if (fakeError != null) { + throw fakeError; + } + } } static class MockItem implements VersionedData { From 3bd558fa9be4cf3c7314602ef1aeb4054ae892a7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 13:00:38 -0800 Subject: [PATCH 246/327] fix comments --- .../launchdarkly/client/FeatureStoreCacheConfig.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 6b2fbaabb..0436f5b8e 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -118,10 +118,10 @@ private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleVal /** * Returns true if caching will be enabled. - * @return true if the cache TTL is non-zero + * @return true if the cache TTL is greater than 0 */ public boolean isEnabled() { - return getCacheTime() != 0; + return getCacheTime() > 0; } /** @@ -190,6 +190,12 @@ public StaleValuesPolicy getStaleValuesPolicy() { * after this amount of time from the time when they were originally cached. *

    * If the value is zero, caching is disabled. + *

    + * If the value is negative, data is cached forever (i.e. it will only be read again from the database + * if the SDK is restarted). Use the "cached forever" mode with caution: it means that in a scenario + * where multiple processes are sharing the database, and the current process loses connectivity to + * LaunchDarkly while other processes are still receiving updates and writing them to the database, + * the current process will have stale data. * * @param cacheTime the cache TTL in whatever units you wish * @param timeUnit the time unit From 3d5381f9168e35ee234c4e5c5337c255c599b583 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 13:06:52 -0800 Subject: [PATCH 247/327] fix merge error --- .../java/com/launchdarkly/client/FeatureStoreCacheConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 0436f5b8e..0c3f4fd82 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -118,10 +118,10 @@ private FeatureStoreCacheConfig(long cacheTime, TimeUnit cacheTimeUnit, StaleVal /** * Returns true if caching will be enabled. - * @return true if the cache TTL is greater than 0 + * @return true if the cache TTL is nonzero */ public boolean isEnabled() { - return getCacheTime() > 0; + return getCacheTime() != 0; } /** From c0c6a33392cc94e3d7270d4b91b26f7e4045ae6d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 17 Jan 2020 17:38:28 -0800 Subject: [PATCH 248/327] normalize OS name in diagnostic data --- .../launchdarkly/client/DiagnosticEvent.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 7f517f213..2c310c481 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -57,6 +57,7 @@ static class Init extends DiagnosticEvent { this.configuration = new DiagnosticConfiguration(config); } + @SuppressWarnings("unused") // fields are for JSON serialization only static class DiagnosticConfiguration { private final boolean customBaseURI; private final boolean customEventsURI; @@ -125,16 +126,31 @@ static class DiagnosticSdk { } } + @SuppressWarnings("unused") // fields are for JSON serialization only static class DiagnosticPlatform { private final String name = "Java"; private final String javaVendor = System.getProperty("java.vendor"); private final String javaVersion = System.getProperty("java.version"); private final String osArch = System.getProperty("os.arch"); - private final String osName = System.getProperty("os.name"); + private final String osName = normalizeOsName(System.getProperty("os.name")); private final String osVersion = System.getProperty("os.version"); DiagnosticPlatform() { } + + private static String normalizeOsName(String osName) { + // For our diagnostics data, we prefer the standard names "Linux", "MacOS", and "Windows". + // "Linux" is already what the JRE returns in Linux. In Windows, we get "Windows 10" etc. + if (osName != null) { + if (osName.equals("Mac OS X")) { + return "MacOS"; + } + if (osName.startsWith("Windows")) { + return "Windows"; + } + } + return osName; + } } } } From 1f158356b02952076b7f0d3f14905e8e12174e9c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:16:40 -0800 Subject: [PATCH 249/327] (4.x) add component-scoped configuration for polling and streaming --- .../com/launchdarkly/client/Components.java | 252 ++++++++++++++---- .../client/DefaultFeatureRequestor.java | 14 +- .../com/launchdarkly/client/LDClient.java | 8 +- .../com/launchdarkly/client/LDConfig.java | 106 +++++--- .../launchdarkly/client/PollingProcessor.java | 12 +- .../launchdarkly/client/StreamProcessor.java | 45 ++-- .../launchdarkly/client/UpdateProcessor.java | 4 +- .../PollingDataSourceBuilder.java | 73 +++++ .../StreamingDataSourceBuilder.java | 88 ++++++ .../client/FeatureRequestorTest.java | 38 +-- .../client/LDClientEndToEndTest.java | 24 +- .../client/LDClientEvaluationTest.java | 4 +- .../client/LDClientEventTest.java | 2 +- ...a => LDClientExternalUpdatesOnlyTest.java} | 51 +++- .../client/LDClientOfflineTest.java | 3 +- .../com/launchdarkly/client/LDClientTest.java | 12 +- .../client/PollingProcessorTest.java | 10 +- .../client/StreamProcessorTest.java | 91 +++---- .../com/launchdarkly/client/TestHttpUtil.java | 15 +- 19 files changed, 640 insertions(+), 212 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java rename src/test/java/com/launchdarkly/client/{LDClientLddModeTest.java => LDClientExternalUpdatesOnlyTest.java} (50%) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index e673b8f20..39a700c67 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -1,12 +1,15 @@ package com.launchdarkly.client; import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.client.integrations.PollingDataSourceBuilder; +import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - +import java.io.IOException; import java.net.URI; +import java.util.concurrent.Future; + +import static com.google.common.util.concurrent.Futures.immediateFuture; /** * Provides factories for the standard implementations of LaunchDarkly component interfaces. @@ -20,21 +23,24 @@ public abstract class Components { private static final UpdateProcessorFactory nullUpdateProcessorFactory = new NullUpdateProcessorFactory(); /** - * Returns a factory for the default in-memory implementation of a data store. + * Returns a configuration object for using the default in-memory implementation of a data store. + *

    + * Since it is the default, you do not normally need to call this method, unless you need to create + * a data store instance for testing purposes. *

    * Note that the interface is still named {@link FeatureStoreFactory}, but in a future version it * will be renamed to {@code DataStoreFactory}. * * @return a factory object * @see LDConfig.Builder#dataStore(FeatureStoreFactory) - * @since 4.11.0 + * @since 4.12.0 */ public static FeatureStoreFactory inMemoryDataStore() { return inMemoryFeatureStoreFactory; } /** - * Returns a configurable factory for some implementation of a persistent data store. + * Returns a configuration builder for some implementation of a persistent data store. *

    * This method is used in conjunction with another factory object provided by specific components * such as the Redis integration. The latter provides builder methods for options that are specific @@ -57,7 +63,7 @@ public static FeatureStoreFactory inMemoryDataStore() { * @return a {@link PersistentDataStoreBuilder} * @see LDConfig.Builder#dataStore(FeatureStoreFactory) * @see com.launchdarkly.client.integrations.Redis - * @since 4.11.0 + * @since 4.12.0 */ public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { return new PersistentDataStoreBuilder(storeFactory); @@ -117,27 +123,79 @@ public static EventProcessorFactory defaultEventProcessor() { public static EventProcessorFactory nullEventProcessor() { return nullEventProcessorFactory; } + + /** + * Returns a configuration object for using streaming mode to get feature flag data. + *

    + * By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. To use the + * default behavior, you do not need to call this method. However, if you want to customize the behavior of + * the connection, call this method to obtain a builder, change its properties with the + * {@link StreamingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}: + *

     
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
    +   *         .build();
    +   * 
    + *

    + * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link LDConfig.Builder#reconnectTimeMs(long)}. + *

    + * (Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it + * will be renamed to {@code DataSourceFactory}.) + * + * @return a builder for setting streaming connection properties + * @since 4.12.0 + */ + public static StreamingDataSourceBuilder streamingDataSource() { + return new StreamingDataSourceBuilderImpl(); + } /** - * Returns a factory for the default implementation of the component for receiving feature flag data - * from LaunchDarkly. Based on your configuration, this implementation uses either streaming or - * polling, or does nothing if the client is offline, or in LDD mode. + * Returns a configurable factory for using polling mode to get feature flag data. + *

    + * This is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag + * data from LaunchDarkly. In polling mode, the SDK instead makes a new HTTP request to LaunchDarkly at regular + * intervals. HTTP caching allows it to avoid redundantly downloading data if there have been no changes, but + * polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. + *

    + * To use polling mode, call this method to obtain a builder, change its properties with the + * {@link PollingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}: + *

    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
    +   *         .build();
    +   * 
    + *

    + * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link LDConfig.Builder#pollingIntervalMillis(long)}. However, setting {@link LDConfig.Builder#offline(boolean)} + * to {@code true} will supersede this setting and completely disable network requests. + *

    + * (Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it + * will be renamed to {@code DataSourceFactory}.) * - * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}. - * - * @return a factory object - * @since 4.11.0 - * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) + * @return a builder for setting polling properties + * @since 4.12.0 */ - public static UpdateProcessorFactory defaultDataSource() { - return defaultUpdateProcessorFactory; + public static PollingDataSourceBuilder pollingDataSource() { + return new PollingDataSourceBuilderImpl(); } - + /** - * Deprecated name for {@link #defaultDataSource()}. + * Deprecated method for using the default data source implementation. + *

    + * If you pass the return value of this method to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}, + * the behavior is as follows: + *

      + *
    • If you have set {@link LDConfig.Builder#offline(boolean)} or {@link LDConfig.Builder#useLdd(boolean)} + * to {@code true}, the SDK will not connect to LaunchDarkly for feature flag data. + *
    • If you have set {@link LDConfig.Builder#stream(boolean)} to {@code false}, it will use polling mode-- + * equivalent to using {@link #pollingDataSource()} with the options set by {@link LDConfig.Builder#baseURI(URI)} + * and {@link LDConfig.Builder#pollingIntervalMillis(long)}. + *
    • Otherwise, it will use streaming mode-- equivalent to using {@link #streamingDataSource()} with + * the options set by {@link LDConfig.Builder#streamURI(URI)} and {@link LDConfig.Builder#reconnectTimeMs(long)}. + * * @return a factory object - * @deprecated Use {@link #defaultDataSource()}. + * @deprecated Use {@link #streamingDataSource()}, {@link #pollingDataSource()}, or {@link #externalUpdatesOnly()}. */ @Deprecated public static UpdateProcessorFactory defaultUpdateProcessor() { @@ -145,24 +203,38 @@ 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. - * - * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it - * will be renamed to {@code DataSourceFactory}. + * Returns a configuration object that disables connecting for feature flag updates. + *

      + * Passing this to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)} causes the SDK + * not to retrieve feature flag data from LaunchDarkly, regardless of any other configuration. + * This is normally done if you are using the Relay Proxy + * in "daemon mode", where an external process-- the Relay Proxy-- connects to LaunchDarkly and populates + * a persistent data store with the feature flag data. The data store could also be populated by + * another process that is running the LaunchDarkly SDK. If there is no external process updating + * the data store, then the SDK will not have any feature flag data and will return application + * default values only. + *

      +   *     LDConfig config = new LDConfig.Builder()
      +   *         .dataSource(Components.externalUpdatesOnly())
      +   *         .dataStore(Components.persistentDataStore(Redis.dataStore())) // assuming the Relay Proxy is using Redis
      +   *         .build();
      +   * 
      + *

      + * (Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it + * will be renamed to {@code DataSourceFactory}.) * * @return a factory object - * @since 4.11.0 + * @since 4.12.0 * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) */ - public static UpdateProcessorFactory nullDataSource() { + public static UpdateProcessorFactory externalUpdatesOnly() { return nullUpdateProcessorFactory; } /** - * Deprecated name for {@link #nullDataSource()}. + * Deprecated name for {@link #externalUpdatesOnly()}. * @return a factory object - * @deprecated Use {@link #nullDataSource()}. + * @deprecated Use {@link #externalUpdatesOnly()}. */ @Deprecated public static UpdateProcessorFactory nullUpdateProcessor() { @@ -194,37 +266,115 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { } private static final class DefaultUpdateProcessorFactory implements UpdateProcessorFactory { - // Note, logger uses LDClient class name for backward compatibility - private static final Logger logger = LoggerFactory.getLogger(LDClient.class); - - @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - if (config.offline) { - logger.info("Starting LaunchDarkly client in offline mode"); - return new UpdateProcessor.NullUpdateProcessor(); - } else if (config.useLdd) { - logger.info("Starting LaunchDarkly in LDD mode. Skipping direct feature retrieval."); - return new UpdateProcessor.NullUpdateProcessor(); + // We don't need to check config.offline or config.useLdd here; the former is checked automatically + // by StreamingDataSourceBuilder and PollingDataSourceBuilder, and setting the latter is translated + // into using externalUpdatesOnly() by LDConfig.Builder. + if (config.stream) { + return streamingDataSource() + .baseUri(config.streamURI) + .initialReconnectDelayMillis(config.reconnectTimeMs) + .createUpdateProcessor(sdkKey, config, featureStore); } else { - DefaultFeatureRequestor requestor = new DefaultFeatureRequestor(sdkKey, config); - if (config.stream) { - logger.info("Enabling streaming API"); - return new StreamProcessor(sdkKey, config, requestor, featureStore, null); - } else { - logger.info("Disabling streaming API"); - logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); - return new PollingProcessor(config, requestor, featureStore); - } + return pollingDataSource() + .baseUri(config.baseURI) + .pollIntervalMillis(config.pollingIntervalMillis) + .createUpdateProcessor(sdkKey, config, featureStore); } } } private static final class NullUpdateProcessorFactory implements UpdateProcessorFactory { - @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { - return new UpdateProcessor.NullUpdateProcessor(); + if (config.offline) { + // If they have explicitly called offline(true) to disable everything, we'll log this slightly + // more specific message. + LDClient.logger.info("Starting LaunchDarkly client in offline mode"); + } else { + LDClient.logger.info("LaunchDarkly client will not connect to Launchdarkly for feature flag data"); + } + return new NullUpdateProcessor(); + } + } + + static final class NullUpdateProcessor implements UpdateProcessor { + @Override + public Future start() { + return immediateFuture(null); + } + + @Override + public boolean initialized() { + return true; + } + + @Override + public void close() throws IOException {} + } + + static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder { + @Override + public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (config.offline) { + LDClient.logger.info("Starting LaunchDarkly client in offline mode"); + return Components.externalUpdatesOnly().createUpdateProcessor(sdkKey, config, featureStore); + } + + LDClient.logger.info("Enabling streaming API"); + + URI streamUri = baseUri == null ? LDConfig.DEFAULT_STREAM_URI : baseUri; + URI pollUri; + if (pollingBaseUri != null) { + pollUri = pollingBaseUri; + } else { + // If they have set a custom base URI, and they did *not* set a custom polling URI, then we can + // assume they're using Relay in which case both of those values are the same. + pollUri = baseUri == null ? LDConfig.DEFAULT_BASE_URI : baseUri; + } + + DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( + sdkKey, + config, + pollUri, + false + ); + + return new StreamProcessor( + sdkKey, + config, + requestor, + featureStore, + null, + streamUri, + initialReconnectDelayMillis + ); + } + } + + static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder { + @Override + public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { + // Note, we log startup messages under the LDClient class to keep logs more readable + + if (config.offline) { + LDClient.logger.info("Starting LaunchDarkly client in offline mode"); + return Components.externalUpdatesOnly().createUpdateProcessor(sdkKey, config, featureStore); + } + + LDClient.logger.info("Disabling streaming API"); + LDClient.logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); + + DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( + sdkKey, + config, + baseUri == null ? LDConfig.DEFAULT_BASE_URI : baseUri, + true + ); + return new PollingProcessor(requestor, featureStore, pollIntervalMillis); } } } diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index 99bbd9535..c7cae3d35 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -7,6 +7,7 @@ import java.io.File; import java.io.IOException; +import java.net.URI; import java.util.HashMap; import java.util.Map; @@ -21,6 +22,9 @@ import okhttp3.Request; import okhttp3.Response; +/** + * Implementation of getting flag data via a polling request. Used by both streaming and polling components. + */ class DefaultFeatureRequestor implements FeatureRequestor { private static final Logger logger = LoggerFactory.getLogger(DefaultFeatureRequestor.class); private static final String GET_LATEST_FLAGS_PATH = "/sdk/latest-flags"; @@ -30,18 +34,20 @@ class DefaultFeatureRequestor implements FeatureRequestor { private final String sdkKey; private final LDConfig config; + private final URI baseUri; private final OkHttpClient httpClient; - DefaultFeatureRequestor(String sdkKey, LDConfig config) { + DefaultFeatureRequestor(String sdkKey, LDConfig config, URI baseUri, boolean useCache) { this.sdkKey = sdkKey; - this.config = config; + this.config = config; // this is no longer the source of truth for baseURI, but it can still affect HTTP behavior + this.baseUri = baseUri; OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config, httpBuilder); // HTTP caching is used only for FeatureRequestor. However, when streaming is enabled, HTTP GETs // made by FeatureRequester will always guarantee a new flag state, so we disable the cache. - if (!config.stream) { + if (useCache) { File cacheDir = Files.createTempDir(); Cache cache = new Cache(cacheDir, MAX_HTTP_CACHE_SIZE_BYTES); httpBuilder.cache(cache); @@ -78,7 +84,7 @@ public AllData getAllData() throws IOException, HttpErrorException { private String get(String path) throws IOException, HttpErrorException { Request request = getRequestBuilder(sdkKey) - .url(config.baseURI.resolve(path).toURL()) + .url(baseUri.resolve(path).toURL()) .get() .build(); diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 676c518ed..6d783a3cd 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.gson.JsonElement; +import com.launchdarkly.client.Components.NullUpdateProcessor; import com.launchdarkly.client.value.LDValue; import org.apache.commons.codec.binary.Hex; @@ -30,7 +31,7 @@ * a single {@code LDClient} for the lifetime of their application. */ public final class LDClient implements LDClientInterface { - private static final Logger logger = LoggerFactory.getLogger(LDClient.class); + static final Logger logger = LoggerFactory.getLogger(LDClient.class); private static final String HMAC_ALGORITHM = "HmacSHA256"; static final String CLIENT_VERSION = getClientVersion(); @@ -83,12 +84,13 @@ public LDClient(String sdkKey, LDConfig config) { Components.defaultEventProcessor() : config.eventProcessorFactory; this.eventProcessor = epFactory.createEventProcessor(sdkKey, config); + @SuppressWarnings("deprecation") // defaultUpdateProcessor will be replaced by streamingDataSource once the deprecated config.stream is removed UpdateProcessorFactory upFactory = config.dataSourceFactory == null ? - Components.defaultDataSource() : config.dataSourceFactory; + Components.defaultUpdateProcessor() : config.dataSourceFactory; this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, config, featureStore); Future startFuture = updateProcessor.start(); if (config.startWaitMillis > 0L) { - if (!config.offline && !config.useLdd) { + if (!(updateProcessor instanceof NullUpdateProcessor)) { logger.info("Waiting up to " + config.startWaitMillis + " milliseconds for LaunchDarkly client to start..."); } try { diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index bc73cfb14..bcca40cef 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -2,6 +2,8 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.launchdarkly.client.integrations.PollingDataSourceBuilder; +import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,9 +33,9 @@ public final class LDConfig { private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); 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"); - private static final URI DEFAULT_STREAM_URI = URI.create("https://stream.launchdarkly.com"); + static final URI DEFAULT_BASE_URI = URI.create("https://app.launchdarkly.com"); + static final URI DEFAULT_EVENTS_URI = URI.create("https://events.launchdarkly.com"); + static final URI DEFAULT_STREAM_URI = URI.create("https://stream.launchdarkly.com"); private static final int DEFAULT_CAPACITY = 10000; private static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; private static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; @@ -59,7 +61,6 @@ public final class LDConfig { final FeatureStoreFactory dataStoreFactory; final EventProcessorFactory eventProcessorFactory; final UpdateProcessorFactory dataSourceFactory; - final boolean useLdd; final boolean offline; final boolean allAttributesPrivate; final Set privateAttrNames; @@ -91,7 +92,6 @@ protected LDConfig(Builder builder) { this.dataStoreFactory = builder.dataStoreFactory; this.eventProcessorFactory = builder.eventProcessorFactory; this.dataSourceFactory = builder.dataSourceFactory; - this.useLdd = builder.useLdd; this.offline = builder.offline; this.allAttributesPrivate = builder.allAttributesPrivate; this.privateAttrNames = new HashSet<>(builder.privateAttrNames); @@ -149,15 +149,14 @@ public static class Builder { private String proxyUsername = null; private String proxyPassword = null; private boolean stream = true; - private boolean useLdd = false; private boolean offline = false; private boolean allAttributesPrivate = false; private boolean sendEvents = true; private long pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; private FeatureStore featureStore = null; - private FeatureStoreFactory dataStoreFactory = Components.inMemoryDataStore(); - private EventProcessorFactory eventProcessorFactory = Components.defaultEventProcessor(); - private UpdateProcessorFactory dataSourceFactory = Components.defaultDataSource(); + private FeatureStoreFactory dataStoreFactory = null; + private EventProcessorFactory eventProcessorFactory = null; + private UpdateProcessorFactory dataSourceFactory = null; private long startWaitMillis = DEFAULT_START_WAIT_MILLIS; private int samplingInterval = DEFAULT_SAMPLING_INTERVAL; private long reconnectTimeMillis = DEFAULT_RECONNECT_TIME_MILLIS; @@ -175,11 +174,17 @@ public Builder() { } /** - * Set the base URL of the LaunchDarkly server for this configuration. + * Deprecated method for setting the base URI for the polling service. + *

      + * This method has no effect if you have used {@link #dataSource(UpdateProcessorFactory)} to + * specify polling or streaming options, which is the preferred method. * * @param baseURI the base URL of the LaunchDarkly server for this configuration. * @return the builder + * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseUri(URI)}, + * or {@link Components#pollingDataSource()} with {@link PollingDataSourceBuilder#baseUri(URI)}. */ + @Deprecated public Builder baseURI(URI baseURI) { this.baseURI = baseURI; return this; @@ -197,11 +202,16 @@ public Builder eventsURI(URI eventsURI) { } /** - * Set the base URL of the LaunchDarkly streaming server for this configuration. + * Deprecated method for setting the base URI for the streaming service. + *

      + * This method has no effect if you have used {@link #dataSource(UpdateProcessorFactory)} to + * specify polling or streaming options, which is the preferred method. * * @param streamURI the base URL of the LaunchDarkly streaming server * @return the builder + * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseUri(URI)}. */ + @Deprecated public Builder streamURI(URI streamURI) { this.streamURI = streamURI; return this; @@ -218,7 +228,7 @@ public Builder streamURI(URI streamURI) { * * @param factory the factory object * @return the builder - * @since 4.11.0 + * @since 4.12.0 */ public Builder dataStore(FeatureStoreFactory factory) { this.dataStoreFactory = factory; @@ -257,7 +267,7 @@ public Builder featureStoreFactory(FeatureStoreFactory factory) { * you may choose to use a custom implementation (for instance, a test fixture). * @param factory the factory object * @return the builder - * @since 4.11.0 + * @since 4.12.0 */ public Builder eventProcessor(EventProcessorFactory factory) { this.eventProcessorFactory = factory; @@ -278,15 +288,20 @@ public Builder eventProcessorFactory(EventProcessorFactory factory) { /** * Sets the implementation of the component that receives feature flag data from LaunchDarkly, - * using a factory object. The default is {@link Components#defaultDataSource()}, but - * you may choose to use a custom implementation (for instance, a test fixture). - * + * using a factory object. Depending on the implementation, the factory may be a builder that + * allows you to set other configuration options as well. + *

      + * The default is {@link Components#streamingDataSource()}. You may instead use + * {@link Components#pollingDataSource()}, or a test fixture such as + * {@link com.launchdarkly.client.integrations.FileData#dataSource()}. See those methods + * for details on how to configure them. + *

      * Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version * it will be renamed to {@code DataSourceFactory}. * * @param factory the factory object * @return the builder - * @since 4.11.0 + * @since 4.12.0 */ public Builder dataSource(UpdateProcessorFactory factory) { this.dataSourceFactory = factory; @@ -307,12 +322,18 @@ public Builder updateProcessorFactory(UpdateProcessorFactory factory) { } /** - * Set whether streaming mode should be enabled. By default, streaming is enabled. It should only be - * disabled on the advice of LaunchDarkly support. - * + * Deprecated method for enabling or disabling streaming mode. + *

      + * By default, streaming is enabled. It should only be disabled on the advice of LaunchDarkly support. + *

      + * This method has no effect if you have specified a data source with {@link #dataSource(UpdateProcessorFactory)}, + * which is the preferred method. + * * @param stream whether streaming mode should be enabled * @return the builder + * @deprecated Use {@link Components#streamingDataSource()} or {@link Components#pollingDataSource()}. */ + @Deprecated public Builder stream(boolean stream) { this.stream = stream; return this; @@ -465,20 +486,34 @@ public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustMana } /** - * Set whether this client should use the LaunchDarkly - * relay in daemon mode, versus subscribing to the streaming or polling API. - * + * Deprecated method for using the LaunchDarkly Relay Proxy in daemon mode. + *

      + * See {@link Components#externalUpdatesOnly()} for the preferred way to do this. + * * @param useLdd true to use the relay in daemon mode; false to use streaming or polling * @return the builder + * @deprecated Use {@link Components#externalUpdatesOnly()}. */ + @Deprecated public Builder useLdd(boolean useLdd) { - this.useLdd = useLdd; - return this; + if (useLdd) { + return dataSource(Components.externalUpdatesOnly()); + } else { + return dataSource(null); + } } /** * Set whether this client is offline. - * + *

      + * In offline mode, the SDK will not make network connections to LaunchDarkly for any purpose. Feature + * flag data will only be available if it already exists in the data store, and analytics events will + * not be sent. + *

      + * This is equivalent to calling {@code dataSource(Components.externalUpdatesOnly())} and + * {@code sendEvents(false)}. It overrides any other values you may have set for + * {@link #dataSource(UpdateProcessorFactory)} or {@link #eventProcessor(EventProcessorFactory)}. + * * @param offline when set to true no calls to LaunchDarkly will be made * @return the builder */ @@ -513,12 +548,18 @@ public Builder sendEvents(boolean sendEvents) { } /** - * Set the polling interval (when streaming is disabled). Values less than the default of - * 30000 will be set to the default. + * Deprecated method for setting the polling interval in polling mode. + *

      + * Values less than the default of 30000 will be set to the default. + *

      + * This method has no effect if you have not disabled streaming mode, or if you have specified + * a non-polling data source with {@link #dataSource(UpdateProcessorFactory)}. * * @param pollingIntervalMillis rule update polling interval in milliseconds * @return the builder + * @deprecated Use {@link Components#pollingDataSource()} and {@link PollingDataSourceBuilder#pollIntervalMillis(long)}. */ + @Deprecated public Builder pollingIntervalMillis(long pollingIntervalMillis) { this.pollingIntervalMillis = pollingIntervalMillis; return this; @@ -554,13 +595,16 @@ public Builder samplingInterval(int samplingInterval) { } /** - * The reconnect base time in milliseconds for the streaming connection. The streaming connection - * uses an exponential backoff algorithm (with jitter) for reconnects, but will start the backoff - * with a value near the value specified here. + * Deprecated method for setting the initial reconnect delay for the streaming connection. + *

      + * This method has no effect if you have disabled streaming mode, or if you have specified a + * non-streaming data source with {@link #dataSource(UpdateProcessorFactory)}. * * @param reconnectTimeMs the reconnect time base value in milliseconds * @return the builder + * @deprecated Use {@link Components#streamingDataSource()} and {@link StreamingDataSourceBuilder#initialReconnectDelayMillis(long)}. */ + @Deprecated public Builder reconnectTimeMs(long reconnectTimeMs) { this.reconnectTimeMillis = reconnectTimeMs; return this; diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 436fa4659..d1a0088bf 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -21,15 +21,15 @@ class PollingProcessor implements UpdateProcessor { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); private final FeatureRequestor requestor; - private final LDConfig config; private final FeatureStore store; + private final long pollIntervalMillis; private AtomicBoolean initialized = new AtomicBoolean(false); private ScheduledExecutorService scheduler = null; - PollingProcessor(LDConfig config, FeatureRequestor requestor, FeatureStore featureStore) { - this.requestor = requestor; - this.config = config; + PollingProcessor(FeatureRequestor requestor, FeatureStore featureStore, long pollIntervalMillis) { + this.requestor = requestor; // note that HTTP configuration is applied to the requestor when it is created this.store = featureStore; + this.pollIntervalMillis = pollIntervalMillis; } @Override @@ -47,7 +47,7 @@ public void close() throws IOException { @Override public Future start() { logger.info("Starting LaunchDarkly polling client with interval: " - + config.pollingIntervalMillis + " milliseconds"); + + pollIntervalMillis + " milliseconds"); final SettableFuture initFuture = SettableFuture.create(); ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("LaunchDarkly-PollingProcessor-%d") @@ -75,7 +75,7 @@ public void run() { logger.debug(e.toString(), e); } } - }, 0L, config.pollingIntervalMillis, TimeUnit.MILLISECONDS); + }, 0L, pollIntervalMillis, TimeUnit.MILLISECONDS); return initFuture; } diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index ce76e09ac..389eb6e21 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -36,8 +36,10 @@ final class StreamProcessor implements UpdateProcessor { private static final int DEAD_CONNECTION_INTERVAL_MS = 300 * 1000; private final FeatureStore store; - private final LDConfig config; private final String sdkKey; + private final LDConfig config; + private final URI streamUri; + private final long initialReconnectDelayMillis; private final FeatureRequestor requestor; private final EventSourceCreator eventSourceCreator; private volatile EventSource es; @@ -46,16 +48,26 @@ final class StreamProcessor implements UpdateProcessor { ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing public static interface EventSourceCreator { - EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers); + EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, long initialReconnectDelayMillis, + ConnectionErrorHandler errorHandler, Headers headers); } - StreamProcessor(String sdkKey, LDConfig config, FeatureRequestor requestor, FeatureStore featureStore, - EventSourceCreator eventSourceCreator) { + StreamProcessor( + String sdkKey, + LDConfig config, + FeatureRequestor requestor, + FeatureStore featureStore, + EventSourceCreator eventSourceCreator, + URI streamUri, + long initialReconnectDelayMillis + ) { this.store = featureStore; - this.config = config; this.sdkKey = sdkKey; + this.config = config; // this is no longer the source of truth for streamUri or initialReconnectDelayMillis, but it can affect HTTP behavior this.requestor = requestor; this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : new DefaultEventSourceCreator(); + this.streamUri = streamUri; + this.initialReconnectDelayMillis = initialReconnectDelayMillis; } private ConnectionErrorHandler createDefaultConnectionErrorHandler() { @@ -191,7 +203,8 @@ public void onError(Throwable throwable) { }; es = eventSourceCreator.createEventSource(config, handler, - URI.create(config.streamURI.toASCIIString() + "/all"), + URI.create(streamUri.toASCIIString() + "/all"), + initialReconnectDelayMillis, wrappedConnectionErrorHandler, headers); es.start(); @@ -218,31 +231,29 @@ public boolean initialized() { private static final class PutData { FeatureRequestor.AllData data; - public PutData() { - - } + @SuppressWarnings("unused") // used by Gson + public PutData() { } } private static final class PatchData { String path; JsonElement data; - public PatchData() { - - } + @SuppressWarnings("unused") // used by Gson + public PatchData() { } } private static final class DeleteData { String path; int version; - public DeleteData() { - - } + @SuppressWarnings("unused") // used by Gson + public DeleteData() { } } private class DefaultEventSourceCreator implements EventSourceCreator { - public EventSource createEventSource(final LDConfig config, EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, Headers headers) { + public EventSource createEventSource(final LDConfig config, EventHandler handler, URI streamUri, long initialReconnectDelayMillis, + ConnectionErrorHandler errorHandler, Headers headers) { EventSource.Builder builder = new EventSource.Builder(handler, streamUri) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { @@ -251,7 +262,7 @@ public void configure(OkHttpClient.Builder builder) { }) .connectionErrorHandler(errorHandler) .headers(headers) - .reconnectTimeMs(config.reconnectTimeMs) + .reconnectTimeMs(initialReconnectDelayMillis) .readTimeoutMs(DEAD_CONNECTION_INTERVAL_MS) .connectTimeoutMs(EventSource.DEFAULT_CONNECT_TIMEOUT_MS) .writeTimeoutMs(EventSource.DEFAULT_WRITE_TIMEOUT_MS); diff --git a/src/main/java/com/launchdarkly/client/UpdateProcessor.java b/src/main/java/com/launchdarkly/client/UpdateProcessor.java index 15cc7231e..52bd712ce 100644 --- a/src/main/java/com/launchdarkly/client/UpdateProcessor.java +++ b/src/main/java/com/launchdarkly/client/UpdateProcessor.java @@ -33,8 +33,10 @@ public interface UpdateProcessor extends Closeable { /** * An implementation of {@link UpdateProcessor} that does nothing. * - * @deprecated Use {@link Components#nullUpdateProcessor()} instead of referring to this implementation class directly. + * @deprecated Use {@link Components#externalUpdatesOnly()} instead of referring to this implementation class directly. */ + // This was exposed because everything in an interface is public. The SDK itself no longer refers to this class; + // instead it uses Components.NullUpdateProcessor. @Deprecated static final class NullUpdateProcessor implements UpdateProcessor { @Override diff --git a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java new file mode 100644 index 000000000..2950a7c2e --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java @@ -0,0 +1,73 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.UpdateProcessorFactory; + +import java.net.URI; + +/** + * Contains methods for configuring the polling data source. + *

      + * This is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag + * data from LaunchDarkly. In polling mode, the SDK instead makes a new HTTP request to LaunchDarkly at regular + * intervals. HTTP caching allows it to avoid redundantly downloading data if there have been no changes, but + * polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. + *

      + * To use polling mode, create a builder with {@link Components#pollingDataSource()}, + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}: + *

      + *     LDConfig config = new LDConfig.Builder()
      + *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
      + *         .build();
      + * 
      + *

      + * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link com.launchdarkly.client.LDConfig.Builder#pollingIntervalMillis(long)}. + *

      + * Note that this class is abstract; the actual implementation is created by calling {@link Components#pollingDataSource()}. + * + * @since 4.12.0 + */ +public abstract class PollingDataSourceBuilder implements UpdateProcessorFactory { + /** + * The default and minimum value for {@link #pollIntervalMillis(long)}. + */ + public static final long DEFAULT_POLL_INTERVAL_MILLIS = 30000L; + + protected URI baseUri; + protected long pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS; + + /** + * Sets a custom base URI for the polling service. + *

      + * You will only need to change this value in the following cases: + *

        + *
      • You are using the Relay Proxy. Set + * {@code streamUri} to the base URI of the Relay Proxy instance. + *
      • You are connecting to a test server or anything else other than the standard LaunchDarkly service. + *
      + * + * @param baseUri the base URI of the polling service; null to use the default + * @return the builder + */ + public PollingDataSourceBuilder baseUri(URI baseUri) { + this.baseUri = baseUri; + return this; + } + + /** + * Sets the interval at which the SDK will poll for feature flag updates. + *

      + * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL_MILLIS}. Values less than this will be + * set to the default. + * + * @param pollIntervalMillis the polling interval in milliseconds + * @return the builder + */ + public PollingDataSourceBuilder pollIntervalMillis(long pollIntervalMillis) { + this.pollIntervalMillis = pollIntervalMillis < DEFAULT_POLL_INTERVAL_MILLIS ? + DEFAULT_POLL_INTERVAL_MILLIS : + pollIntervalMillis; + return this; + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java new file mode 100644 index 000000000..c0da8e640 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java @@ -0,0 +1,88 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.UpdateProcessorFactory; + +import java.net.URI; + +/** + * Contains methods for configuring the streaming data source. + *

      + * By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. If you want + * to customize the behavior of the connection, create a builder with {@link Components#streamingDataSource()}, + * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}: + *

      + *     LDConfig config = new LDConfig.Builder()
      + *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
      + *         .build();
      + * 
      + *

      + * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link com.launchdarkly.client.LDConfig.Builder#reconnectTimeMs(long)}. + *

      + * Note that this class is abstract; the actual implementation is created by calling {@link Components#pollingDataSource()}. + * + * @since 4.12.0 + */ +public abstract class StreamingDataSourceBuilder implements UpdateProcessorFactory { + /** + * The default value for {@link #initialReconnectDelayMillis(long)}. + */ + public static final long DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS = 1000; + + protected URI baseUri; + protected URI pollingBaseUri; + protected long initialReconnectDelayMillis = DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; + + /** + * Sets a custom base URI for the streaming service. + *

      + * You will only need to change this value in the following cases: + *

        + *
      • You are using the Relay Proxy. Set + * {@code baseUri} to the base URI of the Relay Proxy instance. + *
      • You are connecting to a test server or a nonstandard endpoint for the LaunchDarkly service. + *
      + * + * @param baseUri the base URI of the streaming service; null to use the default + * @return the builder + */ + public StreamingDataSourceBuilder baseUri(URI baseUri) { + this.baseUri = baseUri; + return this; + } + + /** + * Sets the initial reconnect delay for the streaming connection. + *

      + * The streaming service uses a backoff algorithm (with jitter) every time the connection needs + * to be reestablished. The delay for the first reconnection will start near this value, and then + * increase exponentially for any subsequent connection failures. + *

      + * The default value is {@link #DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS}. + * + * @param initialReconnectDelayMillis the reconnect time base value in milliseconds + * @return the builder + */ + + public StreamingDataSourceBuilder initialReconnectDelayMillis(long initialReconnectDelayMillis) { + this.initialReconnectDelayMillis = initialReconnectDelayMillis; + return this; + } + + /** + * Sets a custom base URI for special polling requests. + *

      + * Even in streaming mode, the SDK sometimes temporarily must do a polling request. You do not need to + * modify this property unless you are connecting to a test server or a nonstandard endpoing for the + * LaunchDarkly service. If you are using the Relay Proxy, + * you only need to set {@link #baseUri(URI)}. + * + * @param pollingBaseUri the polling endpoint URI; null to use the default + * @return the builder + */ + public StreamingDataSourceBuilder pollingBaseUri(URI pollingBaseUri) { + this.pollingBaseUri = pollingBaseUri; + return this; + } +} diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index b80386f10..276cbab00 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -8,7 +8,6 @@ import javax.net.ssl.SSLHandshakeException; -import static com.launchdarkly.client.TestHttpUtil.baseConfig; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.jsonResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; @@ -33,12 +32,23 @@ public class FeatureRequestorTest { private static final String segmentsJson = "{\"" + segment1Key + "\":" + segment1Json + "}"; private static final String allDataJson = "{\"flags\":" + flagsJson + ",\"segments\":" + segmentsJson + "}"; + private DefaultFeatureRequestor makeRequestor(MockWebServer server) { + return makeRequestor(server, LDConfig.DEFAULT); + // We can always use LDConfig.DEFAULT unless we need to modify HTTP properties, since DefaultFeatureRequestor + // no longer uses the deprecated LDConfig.baseUri property. + } + + private DefaultFeatureRequestor makeRequestor(MockWebServer server, LDConfig config) { + URI uri = server.url("").uri(); + return new DefaultFeatureRequestor(sdkKey, config, uri, true); + } + @Test public void requestAllData() throws Exception { MockResponse resp = jsonResponse(allDataJson); try (MockWebServer server = makeStartedServer(resp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try (DefaultFeatureRequestor r = makeRequestor(server)) { FeatureRequestor.AllData data = r.getAllData(); RecordedRequest req = server.takeRequest(); @@ -61,7 +71,7 @@ public void requestFlag() throws Exception { MockResponse resp = jsonResponse(flag1Json); try (MockWebServer server = makeStartedServer(resp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try (DefaultFeatureRequestor r = makeRequestor(server)) { FeatureFlag flag = r.getFlag(flag1Key); RecordedRequest req = server.takeRequest(); @@ -78,7 +88,7 @@ public void requestSegment() throws Exception { MockResponse resp = jsonResponse(segment1Json); try (MockWebServer server = makeStartedServer(resp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try (DefaultFeatureRequestor r = makeRequestor(server)) { Segment segment = r.getSegment(segment1Key); RecordedRequest req = server.takeRequest(); @@ -95,7 +105,7 @@ public void requestFlagNotFound() throws Exception { MockResponse notFoundResp = new MockResponse().setResponseCode(404); try (MockWebServer server = makeStartedServer(notFoundResp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try (DefaultFeatureRequestor r = makeRequestor(server)) { try { r.getFlag(flag1Key); Assert.fail("expected exception"); @@ -111,7 +121,7 @@ public void requestSegmentNotFound() throws Exception { MockResponse notFoundResp = new MockResponse().setResponseCode(404); try (MockWebServer server = makeStartedServer(notFoundResp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try (DefaultFeatureRequestor r = makeRequestor(server)) { try { r.getSegment(segment1Key); fail("expected exception"); @@ -129,7 +139,7 @@ public void requestsAreCached() throws Exception { .setHeader("Cache-Control", "max-age=1000"); try (MockWebServer server = makeStartedServer(cacheableResp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(server).build())) { + try (DefaultFeatureRequestor r = makeRequestor(server)) { FeatureFlag flag1a = r.getFlag(flag1Key); RecordedRequest req1 = server.takeRequest(); @@ -150,7 +160,7 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { MockResponse resp = jsonResponse(flag1Json); try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, basePollingConfig(serverWithCert.server).build())) { + try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server)) { try { r.getFlag(flag1Key); fail("expected exception"); @@ -167,11 +177,11 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { MockResponse resp = jsonResponse(flag1Json); try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { - LDConfig config = basePollingConfig(serverWithCert.server) + LDConfig config = new LDConfig.Builder() .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { + try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); } @@ -184,12 +194,11 @@ public void httpClientCanUseProxyConfig() throws Exception { try (MockWebServer server = makeStartedServer(jsonResponse(flag1Json))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() - .baseURI(fakeBaseUri) .proxyHost(serverUrl.host()) .proxyPort(serverUrl.port()) .build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config, fakeBaseUri, true)) { FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); @@ -198,11 +207,6 @@ public void httpClientCanUseProxyConfig() throws Exception { } } - private LDConfig.Builder basePollingConfig(MockWebServer server) { - return baseConfig(server) - .stream(false); - } - private void verifyHeaders(RecordedRequest req) { assertEquals(sdkKey, req.getHeader("Authorization")); assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, req.getHeader("User-Agent")); diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index a513e9d0d..f60f3633b 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -6,7 +6,8 @@ import org.junit.Test; -import static com.launchdarkly.client.TestHttpUtil.baseConfig; +import static com.launchdarkly.client.TestHttpUtil.basePollingConfig; +import static com.launchdarkly.client.TestHttpUtil.baseStreamingConfig; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.jsonResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; @@ -31,8 +32,8 @@ public void clientStartsInPollingMode() throws Exception { MockResponse resp = jsonResponse(makeAllDataJson()); try (MockWebServer server = makeStartedServer(resp)) { - LDConfig config = baseConfig(server) - .stream(false) + LDConfig config = new LDConfig.Builder() + .dataSource(basePollingConfig(server)) .sendEvents(false) .build(); @@ -48,8 +49,8 @@ public void clientFailsInPollingModeWith401Error() throws Exception { MockResponse resp = new MockResponse().setResponseCode(401); try (MockWebServer server = makeStartedServer(resp)) { - LDConfig config = baseConfig(server) - .stream(false) + LDConfig config = new LDConfig.Builder() + .dataSource(basePollingConfig(server)) .sendEvents(false) .build(); @@ -65,8 +66,8 @@ public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { MockResponse resp = jsonResponse(makeAllDataJson()); try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { - LDConfig config = baseConfig(serverWithCert.server) - .stream(false) + LDConfig config = new LDConfig.Builder() + .dataSource(basePollingConfig(serverWithCert.server)) .sendEvents(false) .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); @@ -85,7 +86,8 @@ public void clientStartsInStreamingMode() throws Exception { MockResponse resp = TestHttpUtil.eventStreamResponse(streamData); try (MockWebServer server = makeStartedServer(resp)) { - LDConfig config = baseConfig(server) + LDConfig config = new LDConfig.Builder() + .dataSource(baseStreamingConfig(server)) .sendEvents(false) .build(); @@ -101,7 +103,8 @@ public void clientFailsInStreamingModeWith401Error() throws Exception { MockResponse resp = new MockResponse().setResponseCode(401); try (MockWebServer server = makeStartedServer(resp)) { - LDConfig config = baseConfig(server) + LDConfig config = new LDConfig.Builder() + .dataSource(baseStreamingConfig(server)) .sendEvents(false) .build(); @@ -119,7 +122,8 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { MockResponse resp = TestHttpUtil.eventStreamResponse(streamData); try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { - LDConfig config = baseConfig(serverWithCert.server) + LDConfig config = new LDConfig.Builder() + .dataSource(baseStreamingConfig(serverWithCert.server)) .sendEvents(false) .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 9db225910..83321c7a7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -36,7 +36,7 @@ public class LDClientEvaluationTest { private LDConfig config = new LDConfig.Builder() .dataStore(specificFeatureStore(featureStore)) .eventProcessor(Components.nullEventProcessor()) - .dataSource(Components.nullDataSource()) + .dataSource(Components.externalUpdatesOnly()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); @@ -266,7 +266,7 @@ public void appropriateErrorForUnexpectedException() throws Exception { LDConfig badConfig = new LDConfig.Builder() .dataStore(specificFeatureStore(badFeatureStore)) .eventProcessor(Components.nullEventProcessor()) - .dataSource(Components.nullDataSource()) + .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { EvaluationDetail expectedResult = EvaluationDetail.fromValue(false, null, diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index caf90b6fe..9e77aa0ec 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -31,7 +31,7 @@ public class LDClientEventTest { private LDConfig config = new LDConfig.Builder() .dataStore(specificFeatureStore(featureStore)) .eventProcessor(specificEventProcessor(eventSink)) - .dataSource(Components.nullDataSource()) + .dataSource(Components.externalUpdatesOnly()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); diff --git a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java b/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java similarity index 50% rename from src/test/java/com/launchdarkly/client/LDClientLddModeTest.java rename to src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java index 21a142823..414fd628a 100644 --- a/src/test/java/com/launchdarkly/client/LDClientLddModeTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientExternalUpdatesOnlyTest.java @@ -14,7 +14,51 @@ import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") -public class LDClientLddModeTest { +public class LDClientExternalUpdatesOnlyTest { + @Test + public void externalUpdatesOnlyClientHasNullUpdateProcessor() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); + } + } + + @Test + public void externalUpdatesOnlyClientHasDefaultEventProcessor() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void externalUpdatesOnlyClientIsInitialized() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertTrue(client.initialized()); + } + } + + @Test + public void externalUpdatesOnlyClientGetsFlagFromFeatureStore() throws IOException { + FeatureStore testFeatureStore = initedFeatureStore(); + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .dataStore(specificFeatureStore(testFeatureStore)) + .build(); + FeatureFlag flag = flagWithValue("key", LDValue.of(true)); + testFeatureStore.upsert(FEATURES, flag); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertTrue(client.boolVariation("key", new LDUser("user"), false)); + } + } + @SuppressWarnings("deprecation") @Test public void lddModeClientHasNullUpdateProcessor() throws IOException { @@ -22,12 +66,13 @@ public void lddModeClientHasNullUpdateProcessor() throws IOException { .useLdd(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(UpdateProcessor.NullUpdateProcessor.class, client.updateProcessor.getClass()); + assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); } } @Test public void lddModeClientHasDefaultEventProcessor() throws IOException { + @SuppressWarnings("deprecation") LDConfig config = new LDConfig.Builder() .useLdd(true) .build(); @@ -38,6 +83,7 @@ public void lddModeClientHasDefaultEventProcessor() throws IOException { @Test public void lddModeClientIsInitialized() throws IOException { + @SuppressWarnings("deprecation") LDConfig config = new LDConfig.Builder() .useLdd(true) .build(); @@ -49,6 +95,7 @@ public void lddModeClientIsInitialized() throws IOException { @Test public void lddModeClientGetsFlagFromFeatureStore() throws IOException { FeatureStore testFeatureStore = initedFeatureStore(); + @SuppressWarnings("deprecation") LDConfig config = new LDConfig.Builder() .useLdd(true) .dataStore(specificFeatureStore(testFeatureStore)) diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 2785fc5d1..20819fee0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -21,14 +21,13 @@ public class LDClientOfflineTest { private static final LDUser user = new LDUser("user"); - @SuppressWarnings("deprecation") @Test public void offlineClientHasNullUpdateProcessor() throws IOException { LDConfig config = new LDConfig.Builder() .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(UpdateProcessor.NullUpdateProcessor.class, client.updateProcessor.getClass()); + assertEquals(Components.NullUpdateProcessor.class, client.updateProcessor.getClass()); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index ae4a20bd1..3c83cdeb9 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -86,8 +86,7 @@ public void constructorThrowsExceptionForNullConfig() throws Exception { @Test public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception { LDConfig config = new LDConfig.Builder() - .stream(false) - .baseURI(URI.create("/fake")) + .dataSource(Components.externalUpdatesOnly()) .startWaitMillis(0) .sendEvents(true) .build(); @@ -99,8 +98,7 @@ public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception @Test public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException { LDConfig config = new LDConfig.Builder() - .stream(false) - .baseURI(URI.create("/fake")) + .dataSource(Components.externalUpdatesOnly()) .startWaitMillis(0) .sendEvents(false) .build(); @@ -112,8 +110,7 @@ public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException @Test public void streamingClientHasStreamProcessor() throws Exception { LDConfig config = new LDConfig.Builder() - .stream(true) - .streamURI(URI.create("http://fake")) + .dataSource(Components.streamingDataSource().baseUri(URI.create("http://fake"))) .startWaitMillis(0) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { @@ -124,8 +121,7 @@ public void streamingClientHasStreamProcessor() throws Exception { @Test public void pollingClientHasPollingProcessor() throws IOException { LDConfig config = new LDConfig.Builder() - .stream(false) - .baseURI(URI.create("http://fake")) + .dataSource(Components.pollingDataSource().baseUri(URI.create("http://fake"))) .startWaitMillis(0) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index b143cc35f..f70663fb6 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -14,13 +14,15 @@ @SuppressWarnings("javadoc") public class PollingProcessorTest { + private static final long LENGTHY_INTERVAL = 60000; + @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.allData = new FeatureRequestor.AllData(new HashMap(), new HashMap()); FeatureStore store = new InMemoryFeatureStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); initFuture.get(1000, TimeUnit.MILLISECONDS); assertTrue(pollingProcessor.initialized()); @@ -34,7 +36,7 @@ public void testConnectionProblem() throws Exception { requestor.ioException = new IOException("This exception is part of a test and yes you should be seeing it."); FeatureStore store = new InMemoryFeatureStore(); - try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, store)) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, store, LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200L, TimeUnit.MILLISECONDS); @@ -80,7 +82,7 @@ public void http500ErrorIsRecoverable() throws Exception { private void testUnrecoverableHttpError(int status) throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.httpException = new HttpErrorException(status); - try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, new InMemoryFeatureStore())) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, new InMemoryFeatureStore(), LENGTHY_INTERVAL)) { long startTime = System.currentTimeMillis(); Future initFuture = pollingProcessor.start(); try { @@ -97,7 +99,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { private void testRecoverableHttpError(int status) throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); requestor.httpException = new HttpErrorException(status); - try (PollingProcessor pollingProcessor = new PollingProcessor(LDConfig.DEFAULT, requestor, new InMemoryFeatureStore())) { + try (PollingProcessor pollingProcessor = new PollingProcessor(requestor, new InMemoryFeatureStore(), LENGTHY_INTERVAL)) { Future initFuture = pollingProcessor.start(); try { initFuture.get(200, TimeUnit.MILLISECONDS); diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index ff49176c3..081f490e5 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -23,7 +23,6 @@ import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; -import static com.launchdarkly.client.TestUtil.specificFeatureStore; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.expect; @@ -54,7 +53,6 @@ public class StreamProcessorTest extends EasyMockSupport { "data: {\"data\":{\"flags\":{},\"segments\":{}}}\n\n"; private InMemoryFeatureStore featureStore; - private LDConfig.Builder configBuilder; private FeatureRequestor mockRequestor; private EventSource mockEventSource; private EventHandler eventHandler; @@ -65,39 +63,37 @@ public class StreamProcessorTest extends EasyMockSupport { @Before public void setup() { featureStore = new InMemoryFeatureStore(); - configBuilder = new LDConfig.Builder().dataStore(specificFeatureStore(featureStore)); mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createStrictMock(EventSource.class); } @Test public void streamUriHasCorrectEndpoint() { - LDConfig config = configBuilder.streamURI(STREAM_URI).build(); - createStreamProcessor(SDK_KEY, config).start(); + createStreamProcessor(STREAM_URI).start(); assertEquals(URI.create(STREAM_URI.toString() + "/all"), actualStreamUri); } @Test public void headersHaveAuthorization() { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); assertEquals(SDK_KEY, headers.get("Authorization")); } @Test public void headersHaveUserAgent() { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); assertEquals("JavaClient/" + LDClient.CLIENT_VERSION, headers.get("User-Agent")); } @Test public void headersHaveAccept() { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); assertEquals("text/event-stream", headers.get("Accept")); } @Test public void putCausesFeatureToBeStored() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{\"" + FEATURE1_KEY + "\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}," + "\"segments\":{}}}"); @@ -108,7 +104,7 @@ public void putCausesFeatureToBeStored() throws Exception { @Test public void putCausesSegmentToBeStored() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{\"" + SEGMENT1_KEY + "\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}}}"); eventHandler.onMessage("put", event); @@ -118,27 +114,27 @@ public void putCausesSegmentToBeStored() throws Exception { @Test public void storeNotInitializedByDefault() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); assertFalse(featureStore.initialized()); } @Test public void putCausesStoreToBeInitialized() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); assertTrue(featureStore.initialized()); } @Test public void processorNotInitializedByDefault() throws Exception { - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); assertFalse(sp.initialized()); } @Test public void putCausesProcessorToBeInitialized() throws Exception { - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); eventHandler.onMessage("put", emptyPutEvent()); assertTrue(sp.initialized()); @@ -146,14 +142,14 @@ public void putCausesProcessorToBeInitialized() throws Exception { @Test public void futureIsNotSetByDefault() throws Exception { - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); Future future = sp.start(); assertFalse(future.isDone()); } @Test public void putCausesFutureToBeSet() throws Exception { - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); Future future = sp.start(); eventHandler.onMessage("put", emptyPutEvent()); assertTrue(future.isDone()); @@ -161,7 +157,7 @@ public void putCausesFutureToBeSet() throws Exception { @Test public void patchUpdatesFeature() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); String path = "/flags/" + FEATURE1_KEY; @@ -174,7 +170,7 @@ public void patchUpdatesFeature() throws Exception { @Test public void patchUpdatesSegment() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); String path = "/segments/" + SEGMENT1_KEY; @@ -187,7 +183,7 @@ public void patchUpdatesSegment() throws Exception { @Test public void deleteDeletesFeature() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); featureStore.upsert(FEATURES, FEATURE); @@ -201,7 +197,7 @@ public void deleteDeletesFeature() throws Exception { @Test public void deleteDeletesSegment() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); featureStore.upsert(SEGMENTS, SEGMENT); @@ -215,7 +211,7 @@ public void deleteDeletesSegment() throws Exception { @Test public void indirectPutRequestsAndStoresFeature() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); @@ -226,7 +222,7 @@ public void indirectPutRequestsAndStoresFeature() throws Exception { @Test public void indirectPutInitializesStore() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); @@ -237,7 +233,7 @@ public void indirectPutInitializesStore() throws Exception { @Test public void indirectPutInitializesProcessor() throws Exception { - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); sp.start(); setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); @@ -249,7 +245,7 @@ public void indirectPutInitializesProcessor() throws Exception { @Test public void indirectPutSetsFuture() throws Exception { - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); Future future = sp.start(); setupRequestorToReturnAllDataWithFlag(FEATURE); replayAll(); @@ -261,7 +257,7 @@ public void indirectPutSetsFuture() throws Exception { @Test public void indirectPatchRequestsAndUpdatesFeature() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); expect(mockRequestor.getFlag(FEATURE1_KEY)).andReturn(FEATURE); replayAll(); @@ -273,7 +269,7 @@ public void indirectPatchRequestsAndUpdatesFeature() throws Exception { @Test public void indirectPatchRequestsAndUpdatesSegment() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); expect(mockRequestor.getSegment(SEGMENT1_KEY)).andReturn(SEGMENT); replayAll(); @@ -285,13 +281,13 @@ public void indirectPatchRequestsAndUpdatesSegment() throws Exception { @Test public void unknownEventTypeDoesNotThrowException() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("what", new MessageEvent("")); } @Test public void streamWillReconnectAfterGeneralIOException() throws Exception { - createStreamProcessor(SDK_KEY, configBuilder.build()).start(); + createStreamProcessor(STREAM_URI).start(); ConnectionErrorHandler.Action action = errorHandler.onConnectionError(new IOException()); assertEquals(ConnectionErrorHandler.Action.PROCEED, action); } @@ -336,12 +332,7 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { try (TestHttpUtil.ServerWithCert server = new TestHttpUtil.ServerWithCert()) { server.server.enqueue(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA)); - LDConfig config = new LDConfig.Builder() - .streamURI(server.uri()) - .build(); - - try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + try (StreamProcessor sp = createStreamProcessorWithRealHttp(LDConfig.DEFAULT, server.uri())) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -360,12 +351,10 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { server.server.enqueue(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA)); LDConfig config = new LDConfig.Builder() - .streamURI(server.uri()) .sslSocketFactory(server.sslClient.socketFactory, server.sslClient.trustManager) // allows us to trust the self-signed cert .build(); - try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, server.uri())) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -382,13 +371,11 @@ public void httpClientCanUseProxyConfig() throws Exception { try (MockWebServer server = makeStartedServer(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() - .streamURI(fakeStreamUri) .proxyHost(serverUrl.host()) .proxyPort(serverUrl.port()) .build(); - try (StreamProcessor sp = new StreamProcessor("sdk-key", config, - mockRequestor, featureStore, null)) { + try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, fakeStreamUri)) { sp.connectionErrorHandler = errorSink; Future ready = sp.start(); ready.get(); @@ -411,7 +398,7 @@ public Action onConnectionError(Throwable t) { private void testUnrecoverableHttpError(int status) throws Exception { UnsuccessfulResponseException e = new UnsuccessfulResponseException(status); long startTime = System.currentTimeMillis(); - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); @@ -430,7 +417,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { private void testRecoverableHttpError(int status) throws Exception { UnsuccessfulResponseException e = new UnsuccessfulResponseException(status); long startTime = System.currentTimeMillis(); - StreamProcessor sp = createStreamProcessor(SDK_KEY, configBuilder.build()); + StreamProcessor sp = createStreamProcessor(STREAM_URI); Future initFuture = sp.start(); ConnectionErrorHandler.Action action = errorHandler.onConnectionError(e); @@ -446,10 +433,20 @@ private void testRecoverableHttpError(int status) throws Exception { assertFalse(sp.initialized()); } - private StreamProcessor createStreamProcessor(String sdkKey, LDConfig config) { - return new StreamProcessor(sdkKey, config, mockRequestor, featureStore, new StubEventSourceCreator()); + private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { + return new StreamProcessor(SDK_KEY, config, mockRequestor, featureStore, new StubEventSourceCreator(), + streamUri, config.reconnectTimeMs); } - + + private StreamProcessor createStreamProcessor(URI streamUri) { + return createStreamProcessor(LDConfig.DEFAULT, streamUri); + } + + private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { + return new StreamProcessor(SDK_KEY, config, mockRequestor, featureStore, null, + streamUri, config.reconnectTimeMs); + } + private String featureJson(String key, int version) { return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"on\":true}"; } @@ -477,8 +474,8 @@ private void assertSegmentInStore(Segment segment) { } private class StubEventSourceCreator implements StreamProcessor.EventSourceCreator { - public EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, ConnectionErrorHandler errorHandler, - Headers headers) { + public EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, + long initialReconnectDelay, ConnectionErrorHandler errorHandler, Headers headers) { StreamProcessorTest.this.eventHandler = handler; StreamProcessorTest.this.actualStreamUri = streamUri; StreamProcessorTest.this.errorHandler = errorHandler; diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 8f86c7b1b..035ff814e 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -1,5 +1,8 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.PollingDataSourceBuilder; +import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; + import java.io.Closeable; import java.io.IOException; import java.net.InetAddress; @@ -30,12 +33,12 @@ static ServerWithCert httpsServerWithSelfSignedCert(MockResponse... responses) t return ret; } - static LDConfig.Builder baseConfig(MockWebServer server) { - URI uri = server.url("").uri(); - return new LDConfig.Builder() - .baseURI(uri) - .streamURI(uri) - .eventsURI(uri); + static StreamingDataSourceBuilder baseStreamingConfig(MockWebServer server) { + return Components.streamingDataSource().baseUri(server.url("").uri()); + } + + static PollingDataSourceBuilder basePollingConfig(MockWebServer server) { + return Components.pollingDataSource().baseUri(server.url("").uri()); } static MockResponse jsonResponse(String body) { From 205496d0bee6bb8e8214201484e5c6a039af1823 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:19:03 -0800 Subject: [PATCH 250/327] update package comment --- .../com/launchdarkly/client/integrations/package-info.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/integrations/package-info.java b/src/main/java/com/launchdarkly/client/integrations/package-info.java index 589a2c63a..079858106 100644 --- a/src/main/java/com/launchdarkly/client/integrations/package-info.java +++ b/src/main/java/com/launchdarkly/client/integrations/package-info.java @@ -1,5 +1,6 @@ /** - * This package contains integration tools for connecting the SDK to other software components. + * This package contains integration tools for connecting the SDK to other software components, or + * configuring how it connects to LaunchDarkly. *

      * In the current main LaunchDarkly Java SDK library, this package contains {@link com.launchdarkly.client.integrations.Redis} * (for using Redis as a store for flag data) and {@link com.launchdarkly.client.integrations.FileData} From be9a7fd49a3706933f3ede940494a78a3a2679bc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:25:28 -0800 Subject: [PATCH 251/327] javadoc fixes --- .../java/com/launchdarkly/client/Components.java | 13 +++++++------ .../integrations/PollingDataSourceBuilder.java | 4 ++-- .../integrations/StreamingDataSourceBuilder.java | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 39a700c67..0c3a960ce 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -131,11 +131,11 @@ public static EventProcessorFactory nullEventProcessor() { * default behavior, you do not need to call this method. However, if you want to customize the behavior of * the connection, call this method to obtain a builder, change its properties with the * {@link StreamingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}: - *

       
      +   * 
       
          *     LDConfig config = new LDConfig.Builder()
          *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
          *         .build();
      -   * 
      + *
      *

      * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, * such as {@link LDConfig.Builder#reconnectTimeMs(long)}. @@ -160,11 +160,11 @@ public static StreamingDataSourceBuilder streamingDataSource() { *

      * To use polling mode, call this method to obtain a builder, change its properties with the * {@link PollingDataSourceBuilder} methods, and pass it to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}: - *

      +   * 
      
          *     LDConfig config = new LDConfig.Builder()
          *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
          *         .build();
      -   * 
      + *
      *

      * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, * such as {@link LDConfig.Builder#pollingIntervalMillis(long)}. However, setting {@link LDConfig.Builder#offline(boolean)} @@ -193,6 +193,7 @@ public static PollingDataSourceBuilder pollingDataSource() { * and {@link LDConfig.Builder#pollingIntervalMillis(long)}. *

    • Otherwise, it will use streaming mode-- equivalent to using {@link #streamingDataSource()} with * the options set by {@link LDConfig.Builder#streamURI(URI)} and {@link LDConfig.Builder#reconnectTimeMs(long)}. + *
    * * @return a factory object * @deprecated Use {@link #streamingDataSource()}, {@link #pollingDataSource()}, or {@link #externalUpdatesOnly()}. @@ -213,12 +214,12 @@ public static UpdateProcessorFactory defaultUpdateProcessor() { * another process that is running the LaunchDarkly SDK. If there is no external process updating * the data store, then the SDK will not have any feature flag data and will return application * default values only. - *
    +   * 
    
        *     LDConfig config = new LDConfig.Builder()
        *         .dataSource(Components.externalUpdatesOnly())
        *         .dataStore(Components.persistentDataStore(Redis.dataStore())) // assuming the Relay Proxy is using Redis
        *         .build();
    -   * 
    + *
    *

    * (Note that the interface is still named {@link UpdateProcessorFactory}, but in a future version it * will be renamed to {@code DataSourceFactory}.) diff --git a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java index 2950a7c2e..1628e6b4a 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java @@ -15,11 +15,11 @@ *

    * To use polling mode, create a builder with {@link Components#pollingDataSource()}, * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}: - *

    + * 
    
      *     LDConfig config = new LDConfig.Builder()
      *         .dataSource(Components.pollingDataSource().pollIntervalMillis(45000))
      *         .build();
    - * 
    + *
    *

    * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, * such as {@link com.launchdarkly.client.LDConfig.Builder#pollingIntervalMillis(long)}. diff --git a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java index c0da8e640..0c54c8cdc 100644 --- a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java @@ -11,11 +11,11 @@ * By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. If you want * to customize the behavior of the connection, create a builder with {@link Components#streamingDataSource()}, * change its properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#dataSource(UpdateProcessorFactory)}: - *

    + * 
    
      *     LDConfig config = new LDConfig.Builder()
      *         .dataSource(Components.streamingDataSource().initialReconnectDelayMillis(500))
      *         .build();
    - * 
    + *
    *

    * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, * such as {@link com.launchdarkly.client.LDConfig.Builder#reconnectTimeMs(long)}. From 0b915b219b55466182284ddbfce10c14fd152c9b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:29:48 -0800 Subject: [PATCH 252/327] javadoc fixes --- .../java/com/launchdarkly/client/FeatureStoreCacheConfig.java | 4 ++-- .../com/launchdarkly/client/integrations/CacheMonitor.java | 4 ++-- .../java/com/launchdarkly/client/integrations/FileData.java | 4 ++-- .../client/integrations/FileDataSourceBuilder.java | 2 +- .../client/integrations/PersistentDataStoreBuilder.java | 2 +- src/main/java/com/launchdarkly/client/integrations/Redis.java | 2 +- .../client/integrations/RedisDataStoreBuilder.java | 2 +- .../client/interfaces/PersistentDataStoreFactory.java | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java index 010886791..4c73d2f53 100644 --- a/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java +++ b/src/main/java/com/launchdarkly/client/FeatureStoreCacheConfig.java @@ -96,7 +96,7 @@ public enum StaleValuesPolicy { /** * Used internally for backward compatibility. * @return the equivalent enum value - * @since 4.11.0 + * @since 4.12.0 */ public PersistentDataStoreBuilder.StaleValuesPolicy toNewEnum() { switch (this) { @@ -113,7 +113,7 @@ public PersistentDataStoreBuilder.StaleValuesPolicy toNewEnum() { * Used internally for backward compatibility. * @param policy the enum value in the new API * @return the equivalent enum value - * @since 4.11.0 + * @since 4.12.0 */ public static StaleValuesPolicy fromNewEnum(PersistentDataStoreBuilder.StaleValuesPolicy policy) { switch (policy) { diff --git a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java index 618edc63d..977982d9f 100644 --- a/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java +++ b/src/main/java/com/launchdarkly/client/integrations/CacheMonitor.java @@ -7,7 +7,7 @@ * A conduit that an application can use to monitor caching behavior of a persistent data store. * * @see PersistentDataStoreBuilder#cacheMonitor(CacheMonitor) - * @since 4.11.0 + * @since 4.12.0 */ public final class CacheMonitor { private Callable source; @@ -48,7 +48,7 @@ public CacheStats getCacheStats() { * internally, but is not guaranteed to always do so, and to avoid embedding Guava API details in * the SDK API this is provided as a separate class. * - * @since 4.11.0 + * @since 4.12.0 */ public static final class CacheStats { private final long hitCount; diff --git a/src/main/java/com/launchdarkly/client/integrations/FileData.java b/src/main/java/com/launchdarkly/client/integrations/FileData.java index a6f65f3e2..9771db552 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileData.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileData.java @@ -7,7 +7,7 @@ * typically be used in a test environment, to operate using a predetermined feature flag state * without an actual LaunchDarkly connection. See {@link #dataSource()} for details. * - * @since 4.11.0 + * @since 4.12.0 */ public abstract class FileData { /** @@ -106,7 +106,7 @@ public abstract class FileData { * duplicate key-- it will not load flags from any of the files. * * @return a data source configuration object - * @since 4.11.0 + * @since 4.12.0 */ public static FileDataSourceBuilder dataSource() { return new FileDataSourceBuilder(); diff --git a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java index 4c3cd1993..49df9e3de 100644 --- a/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/FileDataSourceBuilder.java @@ -18,7 +18,7 @@ *

    * For more details, see {@link FileData}. * - * @since 4.11.0 + * @since 4.12.0 */ public final class FileDataSourceBuilder implements UpdateProcessorFactory { private final List sources = new ArrayList<>(); diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java index 43a1b42b3..f3c5a9261 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java @@ -33,7 +33,7 @@ * In this example, {@code .url()} is an option specifically for the Redis integration, whereas * {@code ttlSeconds()} is an option that can be used for any persistent data store. * - * @since 4.11.0 + * @since 4.12.0 */ @SuppressWarnings("deprecation") public final class PersistentDataStoreBuilder implements FeatureStoreFactory { diff --git a/src/main/java/com/launchdarkly/client/integrations/Redis.java b/src/main/java/com/launchdarkly/client/integrations/Redis.java index 7a167ae9d..90bed1e00 100644 --- a/src/main/java/com/launchdarkly/client/integrations/Redis.java +++ b/src/main/java/com/launchdarkly/client/integrations/Redis.java @@ -3,7 +3,7 @@ /** * Integration between the LaunchDarkly SDK and Redis. * - * @since 4.11.0 + * @since 4.12.0 */ public abstract class Redis { /** diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index 70b25b792..8ea2dbd82 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -32,7 +32,7 @@ * .build(); * * - * @since 4.11.0 + * @since 4.12.0 */ public final class RedisDataStoreBuilder implements PersistentDataStoreFactory { /** diff --git a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java index 8931247d3..16a5b5544 100644 --- a/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java +++ b/src/main/java/com/launchdarkly/client/interfaces/PersistentDataStoreFactory.java @@ -10,7 +10,7 @@ * {@link com.launchdarkly.client.Components#persistentDataStore}. * * @see com.launchdarkly.client.Components - * @since 4.11.0 + * @since 4.12.0 */ public interface PersistentDataStoreFactory { /** From ffce8a4f25db3c11abce53f231006da7fa3cea7d Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:33:59 -0800 Subject: [PATCH 253/327] misc cleanup --- src/main/java/com/launchdarkly/client/Components.java | 7 +++++-- src/main/java/com/launchdarkly/client/LDConfig.java | 4 ++-- .../client/integrations/RedisDataStoreImpl.java | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 0c3a960ce..c0e1ce774 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -22,6 +22,8 @@ public abstract class Components { private static final UpdateProcessorFactory defaultUpdateProcessorFactory = new DefaultUpdateProcessorFactory(); private static final UpdateProcessorFactory nullUpdateProcessorFactory = new NullUpdateProcessorFactory(); + private Components() {} + /** * Returns a configuration object for using the default in-memory implementation of a data store. *

    @@ -300,6 +302,7 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea } } + // Package-private for visibility in tests static final class NullUpdateProcessor implements UpdateProcessor { @Override public Future start() { @@ -315,7 +318,7 @@ public boolean initialized() { public void close() throws IOException {} } - static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder { + private static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder { @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { // Note, we log startup messages under the LDClient class to keep logs more readable @@ -356,7 +359,7 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea } } - static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder { + private static final class PollingDataSourceBuilderImpl extends PollingDataSourceBuilder { @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { // Note, we log startup messages under the LDClient class to keep logs more readable diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index bcca40cef..68f74e48e 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -40,12 +40,12 @@ public final class LDConfig { private static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; private static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; private static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 5; - private static final long MIN_POLLING_INTERVAL_MILLIS = 30000L; + private static final long MIN_POLLING_INTERVAL_MILLIS = PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS; private static final long DEFAULT_START_WAIT_MILLIS = 5000L; private static final int DEFAULT_SAMPLING_INTERVAL = 0; private static final int DEFAULT_USER_KEYS_CAPACITY = 1000; private static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; - private static final long DEFAULT_RECONNECT_TIME_MILLIS = 1000; + private static final long DEFAULT_RECONNECT_TIME_MILLIS = StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; protected static final LDConfig DEFAULT = new Builder().build(); diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java index 24e3968b7..c81a02762 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java @@ -22,7 +22,7 @@ import redis.clients.jedis.Transaction; import redis.clients.util.JedisURIHelper; -class RedisDataStoreImpl implements FeatureStoreCore { +final class RedisDataStoreImpl implements FeatureStoreCore { private static final Logger logger = LoggerFactory.getLogger(RedisDataStoreImpl.class); private final JedisPool pool; From 73f07b11a1d7b7fa32f20c13083d8c3dc221cbce Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:39:41 -0800 Subject: [PATCH 254/327] misc cleanup --- .../java/com/launchdarkly/client/DefaultFeatureRequestor.java | 2 +- src/main/java/com/launchdarkly/client/EventOutputFormatter.java | 2 +- src/main/java/com/launchdarkly/client/HttpErrorException.java | 2 +- src/main/java/com/launchdarkly/client/PollingProcessor.java | 2 +- src/main/java/com/launchdarkly/client/SemanticVersion.java | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index c7cae3d35..dd484ef50 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -25,7 +25,7 @@ /** * Implementation of getting flag data via a polling request. Used by both streaming and polling components. */ -class DefaultFeatureRequestor implements FeatureRequestor { +final class DefaultFeatureRequestor implements FeatureRequestor { private static final Logger logger = LoggerFactory.getLogger(DefaultFeatureRequestor.class); private static final String GET_LATEST_FLAGS_PATH = "/sdk/latest-flags"; private static final String GET_LATEST_SEGMENTS_PATH = "/sdk/latest-segments"; diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index e1db41097..a8428ca69 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -13,7 +13,7 @@ * Rather than creating intermediate objects to represent this schema, we use the Gson streaming * output API to construct JSON directly. */ -class EventOutputFormatter { +final class EventOutputFormatter { private final LDConfig config; EventOutputFormatter(LDConfig config) { diff --git a/src/main/java/com/launchdarkly/client/HttpErrorException.java b/src/main/java/com/launchdarkly/client/HttpErrorException.java index 8f6672bbb..8450e260f 100644 --- a/src/main/java/com/launchdarkly/client/HttpErrorException.java +++ b/src/main/java/com/launchdarkly/client/HttpErrorException.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; @SuppressWarnings("serial") -class HttpErrorException extends Exception { +final class HttpErrorException extends Exception { private final int status; public HttpErrorException(int status) { diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index d1a0088bf..6cc20cb87 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -17,7 +17,7 @@ import static com.launchdarkly.client.Util.httpErrorMessage; import static com.launchdarkly.client.Util.isHttpErrorRecoverable; -class PollingProcessor implements UpdateProcessor { +final class PollingProcessor implements UpdateProcessor { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); private final FeatureRequestor requestor; diff --git a/src/main/java/com/launchdarkly/client/SemanticVersion.java b/src/main/java/com/launchdarkly/client/SemanticVersion.java index 29a124fd4..7e0ef034c 100644 --- a/src/main/java/com/launchdarkly/client/SemanticVersion.java +++ b/src/main/java/com/launchdarkly/client/SemanticVersion.java @@ -7,7 +7,7 @@ * Simple implementation of semantic version parsing and comparison according to the Semantic * Versions 2.0.0 standard (http://semver.org). */ -class SemanticVersion implements Comparable { +final class SemanticVersion implements Comparable { private static Pattern VERSION_REGEX = Pattern.compile( "^(?0|[1-9]\\d*)(\\.(?0|[1-9]\\d*))?(\\.(?0|[1-9]\\d*))?" + From adeb7a8ae7a6b72cc952a794ba329d759214941a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:42:53 -0800 Subject: [PATCH 255/327] rename deprecated LDConfig fields to make it obvious when we're using them --- .../com/launchdarkly/client/Components.java | 9 +++++---- .../java/com/launchdarkly/client/LDConfig.java | 18 +++++++++--------- .../com/launchdarkly/client/LDConfigTest.java | 6 ++++-- .../client/StreamProcessorTest.java | 4 ++-- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index c0e1ce774..3efdafc13 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -276,13 +276,14 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea // into using externalUpdatesOnly() by LDConfig.Builder. if (config.stream) { return streamingDataSource() - .baseUri(config.streamURI) - .initialReconnectDelayMillis(config.reconnectTimeMs) + .baseUri(config.deprecatedStreamURI) + .pollingBaseUri(config.deprecatedBaseURI) + .initialReconnectDelayMillis(config.deprecatedReconnectTimeMs) .createUpdateProcessor(sdkKey, config, featureStore); } else { return pollingDataSource() - .baseUri(config.baseURI) - .pollIntervalMillis(config.pollingIntervalMillis) + .baseUri(config.deprecatedBaseURI) + .pollIntervalMillis(config.deprecatedPollingIntervalMillis) .createUpdateProcessor(sdkKey, config, featureStore); } } diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 68f74e48e..fedbbc94a 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -49,9 +49,9 @@ public final class LDConfig { protected static final LDConfig DEFAULT = new Builder().build(); - final URI baseURI; + final URI deprecatedBaseURI; final URI eventsURI; - final URI streamURI; + final URI deprecatedStreamURI; final int capacity; final int flushInterval; final Proxy proxy; @@ -65,10 +65,10 @@ public final class LDConfig { final boolean allAttributesPrivate; final Set privateAttrNames; final boolean sendEvents; - final long pollingIntervalMillis; + final long deprecatedPollingIntervalMillis; final long startWaitMillis; final int samplingInterval; - final long reconnectTimeMs; + final long deprecatedReconnectTimeMs; final int userKeysCapacity; final int userKeysFlushInterval; final boolean inlineUsersInEvents; @@ -80,13 +80,13 @@ public final class LDConfig { final TimeUnit socketTimeoutUnit; protected LDConfig(Builder builder) { - this.baseURI = builder.baseURI; + this.deprecatedBaseURI = builder.baseURI; this.eventsURI = builder.eventsURI; this.capacity = builder.capacity; this.flushInterval = builder.flushIntervalSeconds; this.proxy = builder.proxy(); this.proxyAuthenticator = builder.proxyAuthenticator(); - this.streamURI = builder.streamURI; + this.deprecatedStreamURI = builder.streamURI; this.stream = builder.stream; this.deprecatedFeatureStore = builder.featureStore; this.dataStoreFactory = builder.dataStoreFactory; @@ -97,13 +97,13 @@ protected LDConfig(Builder builder) { this.privateAttrNames = new HashSet<>(builder.privateAttrNames); this.sendEvents = builder.sendEvents; if (builder.pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { - this.pollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; + this.deprecatedPollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; } else { - this.pollingIntervalMillis = builder.pollingIntervalMillis; + this.deprecatedPollingIntervalMillis = builder.pollingIntervalMillis; } this.startWaitMillis = builder.startWaitMillis; this.samplingInterval = builder.samplingInterval; - this.reconnectTimeMs = builder.reconnectTimeMillis; + this.deprecatedReconnectTimeMs = builder.reconnectTimeMillis; this.userKeysCapacity = builder.userKeysCapacity; this.userKeysFlushInterval = builder.userKeysFlushInterval; this.inlineUsersInEvents = builder.inlineUsersInEvents; diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index f7bfb689a..fbbc881d5 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -71,14 +71,16 @@ public void testProxyAuthPartialConfig() { @Test public void testMinimumPollingIntervalIsEnforcedProperly(){ + @SuppressWarnings("deprecation") LDConfig config = new LDConfig.Builder().pollingIntervalMillis(10L).build(); - assertEquals(30000L, config.pollingIntervalMillis); + assertEquals(30000L, config.deprecatedPollingIntervalMillis); } @Test public void testPollingIntervalIsEnforcedProperly(){ + @SuppressWarnings("deprecation") LDConfig config = new LDConfig.Builder().pollingIntervalMillis(30001L).build(); - assertEquals(30001L, config.pollingIntervalMillis); + assertEquals(30001L, config.deprecatedPollingIntervalMillis); } @Test diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 081f490e5..66608462b 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -435,7 +435,7 @@ private void testRecoverableHttpError(int status) throws Exception { private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { return new StreamProcessor(SDK_KEY, config, mockRequestor, featureStore, new StubEventSourceCreator(), - streamUri, config.reconnectTimeMs); + streamUri, config.deprecatedReconnectTimeMs); } private StreamProcessor createStreamProcessor(URI streamUri) { @@ -444,7 +444,7 @@ private StreamProcessor createStreamProcessor(URI streamUri) { private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { return new StreamProcessor(SDK_KEY, config, mockRequestor, featureStore, null, - streamUri, config.reconnectTimeMs); + streamUri, config.deprecatedReconnectTimeMs); } private String featureJson(String key, int version) { From a1e6f58b1f7b63849bf7c64857b1700142b43eaa Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 11:43:29 -0800 Subject: [PATCH 256/327] comment --- src/main/java/com/launchdarkly/client/LDClient.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 6d783a3cd..8e713fd0f 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -31,7 +31,9 @@ * a single {@code LDClient} for the lifetime of their application. */ public final class LDClient implements LDClientInterface { + // Package-private so other classes can log under the top-level logger's tag static final Logger logger = LoggerFactory.getLogger(LDClient.class); + private static final String HMAC_ALGORITHM = "HmacSHA256"; static final String CLIENT_VERSION = getClientVersion(); From 29c8ea35cc11013ab411bfd9211b890bdf73f750 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 12:32:11 -0800 Subject: [PATCH 257/327] fix deprecated property reference --- .../java/com/launchdarkly/client/DefaultFeatureRequestor.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index dd484ef50..b0bfdc02a 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -36,11 +36,13 @@ final class DefaultFeatureRequestor implements FeatureRequestor { private final LDConfig config; private final URI baseUri; private final OkHttpClient httpClient; + private final boolean useCache; DefaultFeatureRequestor(String sdkKey, LDConfig config, URI baseUri, boolean useCache) { this.sdkKey = sdkKey; this.config = config; // this is no longer the source of truth for baseURI, but it can still affect HTTP behavior this.baseUri = baseUri; + this.useCache = useCache; OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config, httpBuilder); @@ -98,7 +100,7 @@ private String get(String path) throws IOException, HttpErrorException { } logger.debug("Get flag(s) response: " + response.toString() + " with body: " + body); logger.debug("Network response: " + response.networkResponse()); - if(!config.stream) { + if (useCache) { logger.debug("Cache hit count: " + httpClient.cache().hitCount() + " Cache network Count: " + httpClient.cache().networkCount()); logger.debug("Cache response: " + response.cacheResponse()); } From 774f328538ed3d6b44d0115cbac53e229fd98b0e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 12:32:30 -0800 Subject: [PATCH 258/327] javadoc fix --- .../client/integrations/StreamingDataSourceBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java index 0c54c8cdc..b62ca4367 100644 --- a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java @@ -20,7 +20,7 @@ * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, * such as {@link com.launchdarkly.client.LDConfig.Builder#reconnectTimeMs(long)}. *

    - * Note that this class is abstract; the actual implementation is created by calling {@link Components#pollingDataSource()}. + * Note that this class is abstract; the actual implementation is created by calling {@link Components#streamingDataSource()}. * * @since 4.12.0 */ From e43b4b080daa4f00c1731c71d14b24e337307260 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 13:11:54 -0800 Subject: [PATCH 259/327] fix example code --- .../integrations/RedisDataStoreBuilder.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index 8ea2dbd82..5c63ca819 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -23,13 +23,15 @@ * Builder calls can be chained, for example: * *

    
    - * LDConfig config = new LDConfig.Builder()
    - *      .dataStore(
    - *           Redis.dataStore()
    - *               .database(1)
    - *               .caching(FeatureStoreCacheConfig.enabled().ttlSeconds(60))
    - *      )
    - *      .build();
    + *     LDConfig config = new LDConfig.Builder()
    + *         .dataStore(
    + *             Components.persistentDataStore(
    + *                 Redis.dataStore()
    + *                     .url("redis://my-redis-host")
    + *                     .database(1)
    + *             ).cacheSeconds(15)
    + *         )
    + *         .build();
      * 
    * * @since 4.12.0 From 22b1de52d427a3e300086a422acbb8796466ef0e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 14:43:03 -0800 Subject: [PATCH 260/327] (4.x) add component-scoped configuration for events --- .../com/launchdarkly/client/Components.java | 130 ++++++++++-- .../client/DefaultEventProcessor.java | 41 ++-- .../client/DefaultFeatureRequestor.java | 13 +- .../client/EventOutputFormatter.java | 11 +- .../launchdarkly/client/EventProcessor.java | 5 + .../client/EventsConfiguration.java | 34 ++++ .../client/HttpConfiguration.java | 34 ++++ .../com/launchdarkly/client/JsonHelpers.java | 20 ++ .../com/launchdarkly/client/LDClient.java | 3 +- .../com/launchdarkly/client/LDConfig.java | 160 ++++++++------- .../java/com/launchdarkly/client/LDUser.java | 10 +- .../launchdarkly/client/StreamProcessor.java | 21 +- .../java/com/launchdarkly/client/Util.java | 21 +- .../integrations/EventProcessorBuilder.java | 186 ++++++++++++++++++ .../integrations/RedisDataStoreBuilder.java | 18 +- .../client/DefaultEventProcessorTest.java | 97 +++++---- .../launchdarkly/client/EventOutputTest.java | 32 +-- .../client/FeatureRequestorTest.java | 4 +- .../client/LDClientEndToEndTest.java | 13 +- .../client/LDClientEvaluationTest.java | 6 +- .../client/LDClientEventTest.java | 2 +- .../client/LDClientOfflineTest.java | 2 +- .../com/launchdarkly/client/LDClientTest.java | 45 ++++- .../com/launchdarkly/client/LDConfigTest.java | 31 +-- .../com/launchdarkly/client/LDUserTest.java | 33 ++-- .../client/StreamProcessorTest.java | 8 +- .../com/launchdarkly/client/TestUtil.java | 15 ++ .../com/launchdarkly/client/UtilTest.java | 8 +- 28 files changed, 725 insertions(+), 278 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/EventsConfiguration.java create mode 100644 src/main/java/com/launchdarkly/client/HttpConfiguration.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 3efdafc13..a2819b79b 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.EventProcessorBuilder; import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; @@ -22,8 +23,6 @@ public abstract class Components { private static final UpdateProcessorFactory defaultUpdateProcessorFactory = new DefaultUpdateProcessorFactory(); private static final UpdateProcessorFactory nullUpdateProcessorFactory = new NullUpdateProcessorFactory(); - private Components() {} - /** * Returns a configuration object for using the default in-memory implementation of a data store. *

    @@ -106,22 +105,72 @@ 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}). + * Returns a configuration builder for analytics event delivery. + *

    + * The default configuration has events enabled with default settings. If you want to + * customize this behavior, call this method to obtain a builder, change its properties + * with the {@link EventProcessorBuilder} properties, and pass it to {@link LDConfig.Builder#events(EventProcessorFactory)}: + *

    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .event(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
    +   *         .build();
    +   * 
    + * To completely disable sending analytics events, use {@link #noEvents()} instead. + * + * @return a builder for setting streaming connection properties + * @see #noEvents() + * @since 4.12.0 + */ + public static EventProcessorBuilder sendEvents() { + return new EventsConfigBuilderImpl(); + } + + /** + * Deprecated method for using the default analytics events implementation. + *

    + * If you pass the return value of this method to {@link LDConfig.Builder#events(EventProcessorFactory)}, + * the behavior is as follows: + *

      + *
    • If you have set {@link LDConfig.Builder#offline(boolean)} to {@code true}, or + * {@link LDConfig.Builder#sendEvents(boolean)} to {@code false}, the SDK will not send events to + * LaunchDarkly. + *
    • Otherwise, it will send events, using the properties set by the deprecated events configuration + * methods such as {@link LDConfig.Builder#capacity(int)}. + * * @return a factory object - * @see LDConfig.Builder#eventProcessorFactory(EventProcessorFactory) + * @deprecated Use {@link #sendEvents()} or {@link #noEvents}. */ + @Deprecated public static EventProcessorFactory defaultEventProcessor() { return defaultEventProcessorFactory; } /** - * 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. + * Returns a configuration object that disables analytics events. + *

      + * Passing this to {@link LDConfig.Builder#events(EventProcessorFactory)} causes the SDK + * to discard all analytics events and not send them to LaunchDarkly, regardless of any other configuration. + *

      +   *     LDConfig config = new LDConfig.Builder()
      +   *         .eventProcessor(Components.noEvents())
      +   *         .build();
      +   * 
      + * * @return a factory object - * @see LDConfig.Builder#eventProcessorFactory(EventProcessorFactory) + * @see LDConfig.Builder#events(EventProcessorFactory) + * @since 4.12.0 */ + public static EventProcessorFactory noEvents() { + return nullEventProcessorFactory; + } + + /** + * Deprecated name for {@link #noEvents()}. + * @return a factory object + * @see LDConfig.Builder#events(EventProcessorFactory) + * @deprecated Use {@link #noEvents()}. + */ + @Deprecated public static EventProcessorFactory nullEventProcessor() { return nullEventProcessorFactory; } @@ -254,27 +303,51 @@ public FeatureStore createFeatureStore() { private static final class DefaultEventProcessorFactory implements EventProcessorFactory { @Override public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - if (config.offline || !config.sendEvents) { - return new EventProcessor.NullEventProcessor(); + if (config.deprecatedSendEvents && !config.offline) { + return sendEvents() + .allAttributesPrivate(config.deprecatedAllAttributesPrivate) + .baseUri(config.deprecatedEventsURI) + .capacity(config.deprecatedCapacity) + .flushIntervalSeconds(config.deprecatedFlushInterval) + .inlineUsersInEvents(config.deprecatedInlineUsersInEvents) + .privateAttributeNames(config.deprecatedPrivateAttrNames.toArray(new String[config.deprecatedPrivateAttrNames.size()])) + .userKeysCapacity(config.deprecatedUserKeysCapacity) + .userKeysFlushIntervalSeconds(config.deprecatedUserKeysFlushInterval) + .createEventProcessor(sdkKey, config); } else { - return new DefaultEventProcessor(sdkKey, config); + return new NullEventProcessor(); } } } private static final class NullEventProcessorFactory implements EventProcessorFactory { public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { - return new EventProcessor.NullEventProcessor(); + return new NullEventProcessor(); } } + static final class NullEventProcessor implements EventProcessor { + @Override + public void sendEvent(Event e) { + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + } + + // This can be removed once the deprecated polling/streaming config options have been removed. private static final class DefaultUpdateProcessorFactory implements UpdateProcessorFactory { @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { // We don't need to check config.offline or config.useLdd here; the former is checked automatically // by StreamingDataSourceBuilder and PollingDataSourceBuilder, and setting the latter is translated // into using externalUpdatesOnly() by LDConfig.Builder. - if (config.stream) { + if (config.deprecatedStream) { return streamingDataSource() .baseUri(config.deprecatedStreamURI) .pollingBaseUri(config.deprecatedBaseURI) @@ -343,14 +416,14 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( sdkKey, - config, + config.httpConfig, pollUri, false ); return new StreamProcessor( sdkKey, - config, + config.httpConfig, requestor, featureStore, null, @@ -375,11 +448,34 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( sdkKey, - config, + config.httpConfig, baseUri == null ? LDConfig.DEFAULT_BASE_URI : baseUri, true ); return new PollingProcessor(requestor, featureStore, pollIntervalMillis); } } + + private static final class EventsConfigBuilderImpl extends EventProcessorBuilder { + @Override + public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { + if (config.offline) { + return new NullEventProcessor(); + } + return new DefaultEventProcessor(sdkKey, + new EventsConfiguration( + allAttributesPrivate, + capacity, + baseUri, + flushIntervalSeconds, + inlineUsersInEvents, + privateAttrNames, + 0, // deprecated samplingInterval isn't supported in new builder + userKeysCapacity, + userKeysFlushIntervalSeconds + ), + config.httpConfig + ); + } + } } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 56fbd1b16..9fb368218 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -50,8 +50,8 @@ final class DefaultEventProcessor implements EventProcessor { private final AtomicBoolean closed = new AtomicBoolean(false); private volatile boolean inputCapacityExceeded = false; - DefaultEventProcessor(String sdkKey, LDConfig config) { - inbox = new ArrayBlockingQueue<>(config.capacity); + DefaultEventProcessor(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig) { + inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); ThreadFactory threadFactory = new ThreadFactoryBuilder() .setDaemon(true) @@ -60,21 +60,21 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - new EventDispatcher(sdkKey, config, inbox, threadFactory, closed); + new EventDispatcher(sdkKey, eventsConfig, httpConfig, inbox, threadFactory, closed); Runnable flusher = new Runnable() { public void run() { postMessageAsync(MessageType.FLUSH, null); } }; - this.scheduler.scheduleAtFixedRate(flusher, config.flushInterval, config.flushInterval, TimeUnit.SECONDS); + this.scheduler.scheduleAtFixedRate(flusher, eventsConfig.flushIntervalSeconds, eventsConfig.flushIntervalSeconds, TimeUnit.SECONDS); Runnable userKeysFlusher = new Runnable() { public void run() { postMessageAsync(MessageType.FLUSH_USERS, null); } }; - this.scheduler.scheduleAtFixedRate(userKeysFlusher, config.userKeysFlushInterval, config.userKeysFlushInterval, - TimeUnit.SECONDS); + this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushIntervalSeconds, + eventsConfig.userKeysFlushIntervalSeconds, TimeUnit.SECONDS); } @Override @@ -186,7 +186,7 @@ static final class EventDispatcher { private static final int MAX_FLUSH_THREADS = 5; private static final int MESSAGE_BATCH_SIZE = 50; - private final LDConfig config; + private final EventsConfiguration eventsConfig; private final OkHttpClient httpClient; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; @@ -194,15 +194,15 @@ static final class EventDispatcher { private final AtomicLong lastKnownPastTime = new AtomicLong(0); private final AtomicBoolean disabled = new AtomicBoolean(false); - private EventDispatcher(String sdkKey, LDConfig config, + private EventDispatcher(String sdkKey, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, final BlockingQueue inbox, ThreadFactory threadFactory, final AtomicBoolean closed) { - this.config = config; + this.eventsConfig = eventsConfig; this.busyFlushWorkersCount = new AtomicInteger(0); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config, httpBuilder); + configureHttpClientBuilder(httpConfig, httpBuilder); httpClient = httpBuilder.build(); // This queue only holds one element; it represents a flush task that has not yet been @@ -210,8 +210,8 @@ private EventDispatcher(String sdkKey, LDConfig config, // all the workers are busy. final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); - final EventBuffer outbox = new EventBuffer(config.capacity); - final SimpleLRUCache userKeys = new SimpleLRUCache(config.userKeysCapacity); + final EventBuffer outbox = new EventBuffer(eventsConfig.capacity); + final SimpleLRUCache userKeys = new SimpleLRUCache(eventsConfig.userKeysCapacity); Thread mainThread = threadFactory.newThread(new Runnable() { public void run() { @@ -247,7 +247,7 @@ public void handleResponse(Response response, Date responseDate) { } }; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { - SendEventsTask task = new SendEventsTask(sdkKey, config, httpClient, listener, payloadQueue, + SendEventsTask task = new SendEventsTask(sdkKey, eventsConfig, httpClient, listener, payloadQueue, busyFlushWorkersCount, threadFactory); flushWorkers.add(task); } @@ -347,7 +347,7 @@ private void processEvent(Event e, SimpleLRUCache userKeys, Even // For each user we haven't seen before, we add an index event - unless this is already // an identify event for that user. - if (!addFullEvent || !config.inlineUsersInEvents) { + if (!addFullEvent || !eventsConfig.inlineUsersInEvents) { if (e.user != null && e.user.getKey() != null && !noticeUser(e.user, userKeys)) { if (!(e instanceof Event.Identify)) { addIndexEvent = true; @@ -377,7 +377,7 @@ private boolean noticeUser(LDUser user, SimpleLRUCache userKeys) } private boolean shouldSampleEvent() { - return config.samplingInterval <= 0 || random.nextInt(config.samplingInterval) == 0; + return eventsConfig.samplingInterval <= 0 || random.nextInt(eventsConfig.samplingInterval) == 0; } private boolean shouldDebugEvent(Event.FeatureRequest fe) { @@ -486,7 +486,8 @@ private static interface EventResponseListener { private static final class SendEventsTask implements Runnable { private final String sdkKey; - private final LDConfig config; + //private final LDConfig config; + private final EventsConfiguration eventsConfig; private final OkHttpClient httpClient; private final EventResponseListener responseListener; private final BlockingQueue payloadQueue; @@ -496,13 +497,13 @@ private static final class SendEventsTask implements Runnable { private final Thread thread; private final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); // need one instance per task because the date parser isn't thread-safe - SendEventsTask(String sdkKey, LDConfig config, OkHttpClient httpClient, EventResponseListener responseListener, + SendEventsTask(String sdkKey, EventsConfiguration eventsConfig, OkHttpClient httpClient, EventResponseListener responseListener, BlockingQueue payloadQueue, AtomicInteger activeFlushWorkersCount, ThreadFactory threadFactory) { this.sdkKey = sdkKey; - this.config = config; + this.eventsConfig = eventsConfig; this.httpClient = httpClient; - this.formatter = new EventOutputFormatter(config); + this.formatter = new EventOutputFormatter(eventsConfig); this.responseListener = responseListener; this.payloadQueue = payloadQueue; this.activeFlushWorkersCount = activeFlushWorkersCount; @@ -543,7 +544,7 @@ void stop() { } private void postEvents(String json, int outputEventCount) { - String uriStr = config.eventsURI.toString() + "/bulk"; + String uriStr = eventsConfig.eventsUri.toString() + "/bulk"; String eventPayloadId = UUID.randomUUID().toString(); logger.debug("Posting {} event(s) to {} with payload: {}", diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index b0bfdc02a..cdda4ff25 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -11,6 +11,7 @@ import java.util.HashMap; import java.util.Map; +import static com.launchdarkly.client.JsonHelpers.gsonInstance; import static com.launchdarkly.client.Util.configureHttpClientBuilder; import static com.launchdarkly.client.Util.getRequestBuilder; import static com.launchdarkly.client.Util.shutdownHttpClient; @@ -33,19 +34,17 @@ final class DefaultFeatureRequestor implements FeatureRequestor { private static final long MAX_HTTP_CACHE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB private final String sdkKey; - private final LDConfig config; private final URI baseUri; private final OkHttpClient httpClient; private final boolean useCache; - DefaultFeatureRequestor(String sdkKey, LDConfig config, URI baseUri, boolean useCache) { + DefaultFeatureRequestor(String sdkKey, HttpConfiguration httpConfig, URI baseUri, boolean useCache) { this.sdkKey = sdkKey; - this.config = config; // this is no longer the source of truth for baseURI, but it can still affect HTTP behavior this.baseUri = baseUri; this.useCache = useCache; OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config, httpBuilder); + configureHttpClientBuilder(httpConfig, httpBuilder); // HTTP caching is used only for FeatureRequestor. However, when streaming is enabled, HTTP GETs // made by FeatureRequester will always guarantee a new flag state, so we disable the cache. @@ -64,17 +63,17 @@ public void close() { public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return config.gson.fromJson(body, FeatureFlag.class); + return gsonInstance().fromJson(body, FeatureFlag.class); } public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return config.gson.fromJson(body, Segment.class); + return gsonInstance().fromJson(body, Segment.class); } public AllData getAllData() throws IOException, HttpErrorException { String body = get(GET_LATEST_ALL_PATH); - return config.gson.fromJson(body, AllData.class); + return gsonInstance().fromJson(body, AllData.class); } static Map, Map> toVersionedDataMap(AllData allData) { diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index a8428ca69..268226fed 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.gson.Gson; import com.google.gson.stream.JsonWriter; import com.launchdarkly.client.EventSummarizer.CounterKey; import com.launchdarkly.client.EventSummarizer.CounterValue; @@ -14,10 +15,12 @@ * output API to construct JSON directly. */ final class EventOutputFormatter { - private final LDConfig config; + private final EventsConfiguration config; + private final Gson gson; - EventOutputFormatter(LDConfig config) { + EventOutputFormatter(EventsConfiguration config) { this.config = config; + this.gson = JsonHelpers.gsonInstanceForEventsSerialization(config); } int writeOutputEvents(Event[] events, EventSummarizer.EventSummary summary, Writer writer) throws IOException { @@ -180,7 +183,7 @@ private void writeUser(LDUser user, JsonWriter jw) throws IOException { jw.name("user"); // config.gson is already set up to use our custom serializer, which knows about private attributes // and already uses the streaming approach - config.gson.toJson(user, LDUser.class, jw); + gson.toJson(user, LDUser.class, jw); } private void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOException { @@ -188,7 +191,7 @@ private void writeLDValue(String key, LDValue value, JsonWriter jw) throws IOExc return; } jw.name(key); - config.gson.toJson(value, LDValue.class, jw); // LDValue defines its own custom serializer + gson.toJson(value, LDValue.class, jw); // LDValue defines its own custom serializer } // This logic is so that we don't have to define multiple custom serializers for the various reason subclasses. diff --git a/src/main/java/com/launchdarkly/client/EventProcessor.java b/src/main/java/com/launchdarkly/client/EventProcessor.java index fd75598ef..2b756cda6 100644 --- a/src/main/java/com/launchdarkly/client/EventProcessor.java +++ b/src/main/java/com/launchdarkly/client/EventProcessor.java @@ -23,7 +23,12 @@ public interface EventProcessor extends Closeable { /** * Stub implementation of {@link EventProcessor} for when we don't want to send any events. + * + * @deprecated Use {@link Components#noEvents()}. */ + // This was exposed because everything in an interface is public. The SDK itself no longer refers to this class; + // instead it uses Components.NullEventProcessor. + @Deprecated static final class NullEventProcessor implements EventProcessor { @Override public void sendEvent(Event e) { diff --git a/src/main/java/com/launchdarkly/client/EventsConfiguration.java b/src/main/java/com/launchdarkly/client/EventsConfiguration.java new file mode 100644 index 000000000..b487b745d --- /dev/null +++ b/src/main/java/com/launchdarkly/client/EventsConfiguration.java @@ -0,0 +1,34 @@ +package com.launchdarkly.client; + +import com.google.common.collect.ImmutableSet; + +import java.net.URI; +import java.util.Set; + +// Used internally to encapsulate the various config/builder properties for events. +final class EventsConfiguration { + final boolean allAttributesPrivate; + final int capacity; + final URI eventsUri; + final int flushIntervalSeconds; + final boolean inlineUsersInEvents; + final ImmutableSet privateAttrNames; + final int samplingInterval; + final int userKeysCapacity; + final int userKeysFlushIntervalSeconds; + + EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, int flushIntervalSeconds, + boolean inlineUsersInEvents, Set privateAttrNames, int samplingInterval, + int userKeysCapacity, int userKeysFlushIntervalSeconds) { + super(); + this.allAttributesPrivate = allAttributesPrivate; + this.capacity = capacity; + this.eventsUri = eventsUri; + this.flushIntervalSeconds = flushIntervalSeconds; + this.inlineUsersInEvents = inlineUsersInEvents; + this.privateAttrNames = privateAttrNames == null ? ImmutableSet.of() : ImmutableSet.copyOf(privateAttrNames); + this.samplingInterval = samplingInterval; + this.userKeysCapacity = userKeysCapacity; + this.userKeysFlushIntervalSeconds = userKeysFlushIntervalSeconds; + } +} \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/HttpConfiguration.java b/src/main/java/com/launchdarkly/client/HttpConfiguration.java new file mode 100644 index 000000000..0f551c594 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/HttpConfiguration.java @@ -0,0 +1,34 @@ +package com.launchdarkly.client; + +import java.net.Proxy; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.Authenticator; + +// Used internally to encapsulate top-level HTTP configuration that applies to all components. +final class HttpConfiguration { + final int connectTimeout; + final TimeUnit connectTimeoutUnit; + final Proxy proxy; + final Authenticator proxyAuthenticator; + final int socketTimeout; + final TimeUnit socketTimeoutUnit; + final SSLSocketFactory sslSocketFactory; + final X509TrustManager trustManager; + + HttpConfiguration(int connectTimeout, TimeUnit connectTimeoutUnit, Proxy proxy, Authenticator proxyAuthenticator, + int socketTimeout, TimeUnit socketTimeoutUnit, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { + super(); + this.connectTimeout = connectTimeout; + this.connectTimeoutUnit = connectTimeoutUnit; + this.proxy = proxy; + this.proxyAuthenticator = proxyAuthenticator; + this.socketTimeout = socketTimeout; + this.socketTimeoutUnit = socketTimeoutUnit; + this.sslSocketFactory = sslSocketFactory; + this.trustManager = trustManager; + } +} diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/client/JsonHelpers.java index 6e43cd6d5..97f4c95a5 100644 --- a/src/main/java/com/launchdarkly/client/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/client/JsonHelpers.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; @@ -10,6 +11,25 @@ import java.io.IOException; abstract class JsonHelpers { + private static final Gson gson = new Gson(); + + /** + * Returns a shared instance of Gson with default configuration. This should not be used for serializing + * event data, since it does not have any of the configurable behavior related to private attributes. + */ + static Gson gsonInstance() { + return gson; + } + + /** + * Creates a Gson instance that will correctly serialize users for the given configuration (private attributes, etc.). + */ + static Gson gsonInstanceForEventsSerialization(EventsConfiguration config) { + return new GsonBuilder() + .registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(config)) + .create(); + } + /** * Implement this interface on any internal class that needs to do some kind of post-processing after * being unmarshaled from JSON. You must also add the annotation {@code JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory)} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 8e713fd0f..f5b169b0a 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -82,11 +82,12 @@ public LDClient(String sdkKey, LDConfig config) { } this.featureStore = new FeatureStoreClientWrapper(store); + @SuppressWarnings("deprecation") // defaultEventProcessor() will be replaced by sendEvents() once the deprecated config properties are removed EventProcessorFactory epFactory = config.eventProcessorFactory == null ? Components.defaultEventProcessor() : config.eventProcessorFactory; this.eventProcessor = epFactory.createEventProcessor(sdkKey, config); - @SuppressWarnings("deprecation") // defaultUpdateProcessor will be replaced by streamingDataSource once the deprecated config.stream is removed + @SuppressWarnings("deprecation") // defaultUpdateProcessor() will be replaced by streamingDataSource() once the deprecated config.stream is removed UpdateProcessorFactory upFactory = config.dataSourceFactory == null ? Components.defaultUpdateProcessor() : config.dataSourceFactory; this.updateProcessor = upFactory.createUpdateProcessor(sdkKey, config, featureStore); diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index fedbbc94a..30315dc02 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -1,7 +1,7 @@ package com.launchdarkly.client; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; +import com.google.common.collect.ImmutableSet; +import com.launchdarkly.client.integrations.EventProcessorBuilder; import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; @@ -12,9 +12,6 @@ import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URI; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLSocketFactory; @@ -31,7 +28,6 @@ */ public final class LDConfig { private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); - final Gson gson = new GsonBuilder().registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(this)).create(); static final URI DEFAULT_BASE_URI = URI.create("https://app.launchdarkly.com"); static final URI DEFAULT_EVENTS_URI = URI.create("https://events.launchdarkly.com"); @@ -49,71 +45,60 @@ public final class LDConfig { protected static final LDConfig DEFAULT = new Builder().build(); - final URI deprecatedBaseURI; - final URI eventsURI; - final URI deprecatedStreamURI; - final int capacity; - final int flushInterval; - final Proxy proxy; - final Authenticator proxyAuthenticator; - final boolean stream; - final FeatureStore deprecatedFeatureStore; final FeatureStoreFactory dataStoreFactory; final EventProcessorFactory eventProcessorFactory; final UpdateProcessorFactory dataSourceFactory; final boolean offline; - final boolean allAttributesPrivate; - final Set privateAttrNames; - final boolean sendEvents; - final long deprecatedPollingIntervalMillis; final long startWaitMillis; - final int samplingInterval; + final HttpConfiguration httpConfig; + + final URI deprecatedBaseURI; + final URI deprecatedEventsURI; + final URI deprecatedStreamURI; + final int deprecatedCapacity; + final int deprecatedFlushInterval; + final boolean deprecatedStream; + final FeatureStore deprecatedFeatureStore; + final boolean deprecatedAllAttributesPrivate; + final ImmutableSet deprecatedPrivateAttrNames; + final boolean deprecatedSendEvents; + final long deprecatedPollingIntervalMillis; + final int deprecatedSamplingInterval; final long deprecatedReconnectTimeMs; - final int userKeysCapacity; - final int userKeysFlushInterval; - final boolean inlineUsersInEvents; - final SSLSocketFactory sslSocketFactory; - final X509TrustManager trustManager; - final int connectTimeout; - final TimeUnit connectTimeoutUnit; - final int socketTimeout; - final TimeUnit socketTimeoutUnit; + final int deprecatedUserKeysCapacity; + final int deprecatedUserKeysFlushInterval; + final boolean deprecatedInlineUsersInEvents; protected LDConfig(Builder builder) { - this.deprecatedBaseURI = builder.baseURI; - this.eventsURI = builder.eventsURI; - this.capacity = builder.capacity; - this.flushInterval = builder.flushIntervalSeconds; - this.proxy = builder.proxy(); - this.proxyAuthenticator = builder.proxyAuthenticator(); - this.deprecatedStreamURI = builder.streamURI; - this.stream = builder.stream; - this.deprecatedFeatureStore = builder.featureStore; this.dataStoreFactory = builder.dataStoreFactory; this.eventProcessorFactory = builder.eventProcessorFactory; this.dataSourceFactory = builder.dataSourceFactory; this.offline = builder.offline; - this.allAttributesPrivate = builder.allAttributesPrivate; - this.privateAttrNames = new HashSet<>(builder.privateAttrNames); - this.sendEvents = builder.sendEvents; + this.startWaitMillis = builder.startWaitMillis; + + this.deprecatedBaseURI = builder.baseURI; + this.deprecatedEventsURI = builder.eventsURI; + this.deprecatedCapacity = builder.capacity; + this.deprecatedFlushInterval = builder.flushIntervalSeconds; + this.deprecatedStreamURI = builder.streamURI; + this.deprecatedStream = builder.stream; + this.deprecatedFeatureStore = builder.featureStore; + this.deprecatedAllAttributesPrivate = builder.allAttributesPrivate; + this.deprecatedPrivateAttrNames = builder.privateAttrNames; + this.deprecatedSendEvents = builder.sendEvents; if (builder.pollingIntervalMillis < MIN_POLLING_INTERVAL_MILLIS) { this.deprecatedPollingIntervalMillis = MIN_POLLING_INTERVAL_MILLIS; } else { this.deprecatedPollingIntervalMillis = builder.pollingIntervalMillis; } - this.startWaitMillis = builder.startWaitMillis; - this.samplingInterval = builder.samplingInterval; + this.deprecatedSamplingInterval = builder.samplingInterval; this.deprecatedReconnectTimeMs = builder.reconnectTimeMillis; - this.userKeysCapacity = builder.userKeysCapacity; - this.userKeysFlushInterval = builder.userKeysFlushInterval; - this.inlineUsersInEvents = builder.inlineUsersInEvents; - this.sslSocketFactory = builder.sslSocketFactory; - this.trustManager = builder.trustManager; - this.connectTimeout = builder.connectTimeout; - this.connectTimeoutUnit = builder.connectTimeoutUnit; - this.socketTimeout = builder.socketTimeout; - this.socketTimeoutUnit = builder.socketTimeoutUnit; + this.deprecatedUserKeysCapacity = builder.userKeysCapacity; + this.deprecatedUserKeysFlushInterval = builder.userKeysFlushInterval; + this.deprecatedInlineUsersInEvents = builder.inlineUsersInEvents; + Proxy proxy = builder.proxy(); + Authenticator proxyAuthenticator = builder.proxyAuthenticator(); if (proxy != null) { if (proxyAuthenticator != null) { logger.info("Using proxy: " + proxy + " with authentication."); @@ -121,6 +106,10 @@ protected LDConfig(Builder builder) { logger.info("Using proxy: " + proxy + " without authentication."); } } + + this.httpConfig = new HttpConfiguration(builder.connectTimeout, builder.connectTimeoutUnit, + proxy, proxyAuthenticator, builder.socketTimeout, builder.socketTimeoutUnit, + builder.sslSocketFactory, builder.trustManager); } /** @@ -160,7 +149,7 @@ public static class Builder { private long startWaitMillis = DEFAULT_START_WAIT_MILLIS; private int samplingInterval = DEFAULT_SAMPLING_INTERVAL; private long reconnectTimeMillis = DEFAULT_RECONNECT_TIME_MILLIS; - private Set privateAttrNames = new HashSet<>(); + private ImmutableSet privateAttrNames = ImmutableSet.of(); private int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; private int userKeysFlushInterval = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; private boolean inlineUsersInEvents = false; @@ -262,24 +251,26 @@ public Builder featureStoreFactory(FeatureStoreFactory factory) { } /** - * Sets the implementation of {@link EventProcessor} to be used for processing analytics events, - * using a factory object. The default is {@link Components#defaultEventProcessor()}, but - * you may choose to use a custom implementation (for instance, a test fixture). - * @param factory the factory object + * Sets the implementation of {@link EventProcessor} to be used for processing analytics events. + *

      + * The default is {@link Components#sendEvents()}, but you may choose to use a custom implementation + * (for instance, a test fixture), or disable events with {@link Components#noEvents()}. + * + * @param factory a builder/factory object for event configuration * @return the builder * @since 4.12.0 */ - public Builder eventProcessor(EventProcessorFactory factory) { + public Builder events(EventProcessorFactory factory) { this.eventProcessorFactory = factory; return this; } /** - * Deprecated name for {@link #eventProcessor(EventProcessorFactory)}. + * Deprecated name for {@link #events(EventProcessorFactory)}. * @param factory the factory object * @return the builder * @since 4.0.0 - * @deprecated Use {@link #eventProcessor(EventProcessorFactory)}. + * @deprecated Use {@link #events(EventProcessorFactory)}. */ public Builder eventProcessorFactory(EventProcessorFactory factory) { this.eventProcessorFactory = factory; @@ -396,24 +387,26 @@ public Builder socketTimeoutMillis(int socketTimeoutMillis) { } /** - * Set the number of seconds between flushes of the event buffer. Decreasing the flush interval means - * that the event buffer is less likely to reach capacity. The default value is 5 seconds. + * Deprecated method for setting the event buffer flush interval * * @param flushInterval the flush interval in seconds * @return the builder + * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#flushIntervalSeconds(int)}. */ + @Deprecated public Builder flushInterval(int flushInterval) { this.flushIntervalSeconds = flushInterval; return this; } /** - * Set the capacity of the events buffer. The client buffers up to this many events in memory before flushing. If the capacity is exceeded before the buffer is flushed, events will be discarded. - * Increasing the capacity means that events are less likely to be discarded, at the cost of consuming more memory. The default value is 10000 elements. The default flush interval (set by flushInterval) is 5 seconds. + * Deprecated method for setting the capacity of the events buffer. * * @param capacity the capacity of the event buffer * @return the builder + * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#capacity(int)}. */ + @Deprecated public Builder capacity(int capacity) { this.capacity = capacity; return this; @@ -511,8 +504,8 @@ public Builder useLdd(boolean useLdd) { * not be sent. *

      * This is equivalent to calling {@code dataSource(Components.externalUpdatesOnly())} and - * {@code sendEvents(false)}. It overrides any other values you may have set for - * {@link #dataSource(UpdateProcessorFactory)} or {@link #eventProcessor(EventProcessorFactory)}. + * {@code events(Components.noEvents())}. It overrides any other values you may have set for + * {@link #dataSource(UpdateProcessorFactory)} or {@link #events(EventProcessorFactory)}. * * @param offline when set to true no calls to LaunchDarkly will be made * @return the builder @@ -523,25 +516,26 @@ public Builder offline(boolean offline) { } /** - * Set whether or not user attributes (other than the key) should be hidden from LaunchDarkly. If this is true, all - * user attribute values will be private, not just the attributes specified in {@link #privateAttributeNames(String...)}. By default, - * this is false. + * Deprecated method for making all user attributes private. + * * @param allPrivate true if all user attributes should be private * @return the builder + * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#allAttributesPrivate(boolean)}. */ + @Deprecated public Builder allAttributesPrivate(boolean allPrivate) { this.allAttributesPrivate = allPrivate; return this; } /** - * Set whether to send events back to LaunchDarkly. By default, the client will send - * events. This differs from {@link #offline(boolean)} in that it only affects sending - * analytics events, not streaming or polling for events from the server. + * Deprecated method for disabling analytics events. * * @param sendEvents when set to false, no events will be sent to LaunchDarkly * @return the builder + * @deprecated Use {@link Components#noEvents()}. */ + @Deprecated public Builder sendEvents(boolean sendEvents) { this.sendEvents = sendEvents; return this; @@ -611,48 +605,52 @@ public Builder reconnectTimeMs(long reconnectTimeMs) { } /** - * Marks a set of attribute names private. Any users sent to LaunchDarkly with this configuration - * active will have attributes with these names removed. + * Deprecated method for specifying globally private user attributes. * * @param names a set of names that will be removed from user data set to LaunchDarkly * @return the builder + * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#privateAttributeNames(String...)}. */ + @Deprecated public Builder privateAttributeNames(String... names) { - this.privateAttrNames = new HashSet<>(Arrays.asList(names)); + this.privateAttrNames = ImmutableSet.copyOf(names); return this; } /** - * Sets the number of user keys that the event processor can remember at any one time, so that - * duplicate user details will not be sent in analytics events. + * Deprecated method for setting the number of user keys that can be cached for analytics events. * * @param capacity the maximum number of user keys to remember * @return the builder + * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#userKeysCapacity(int)}. */ + @Deprecated public Builder userKeysCapacity(int capacity) { this.userKeysCapacity = capacity; return this; } /** - * Sets the interval in seconds at which the event processor will reset its set of known user keys. The - * default value is five minutes. + * Deprecated method for setting the expiration time of the user key cache for analytics events. * * @param flushInterval the flush interval in seconds * @return the builder + * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#userKeysFlushIntervalSeconds(int)}. */ + @Deprecated public Builder userKeysFlushInterval(int flushInterval) { this.userKeysFlushInterval = flushInterval; return this; } /** - * Sets whether to include full user details in every analytics event. The default is false (events will - * only include the user key, except for one "index" event that provides the full details for the user). + * Deprecated method for setting whether to include full user details in every analytics event. * * @param inlineUsersInEvents true if you want full user details in each event * @return the builder + * @deprecated Use {@link Components#sendEvents()} with {@link EventProcessorBuilder#inlineUsersInEvents(boolean)}. */ + @Deprecated public Builder inlineUsersInEvents(boolean inlineUsersInEvents) { this.inlineUsersInEvents = inlineUsersInEvents; return this; diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index d9473bec2..47d938b1d 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -2,7 +2,6 @@ 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; @@ -179,10 +178,9 @@ public int hashCode() { // 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; + private final EventsConfiguration config; - public UserAdapterWithPrivateAttributeBehavior(LDConfig config) { + public UserAdapterWithPrivateAttributeBehavior(EventsConfiguration config) { this.config = config; } @@ -282,7 +280,7 @@ private void writeCustomAttrs(JsonWriter out, LDUser user, Set privateAt beganObject = true; } out.name(entry.getKey()); - gson.toJson(entry.getValue(), LDValue.class, out); + JsonHelpers.gsonInstance().toJson(entry.getValue(), LDValue.class, out); } } if (beganObject) { @@ -460,7 +458,6 @@ public Builder privateCountry(String s) { * @deprecated As of version 4.10.0. In the next major release the SDK will no longer include the * LDCountryCode class. Applications should use {@link #country(String)} instead. */ - @SuppressWarnings("deprecation") @Deprecated public Builder country(LDCountryCode country) { this.country = country == null ? null : country.getAlpha2(); @@ -475,7 +472,6 @@ public Builder country(LDCountryCode country) { * @deprecated As of version 4.10.0. In the next major release the SDK will no longer include the * LDCountryCode class. Applications should use {@link #privateCountry(String)} instead. */ - @SuppressWarnings("deprecation") @Deprecated public Builder privateCountry(LDCountryCode country) { addPrivate("country"); diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 389eb6e21..196fb237e 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -37,7 +37,7 @@ final class StreamProcessor implements UpdateProcessor { private final FeatureStore store; private final String sdkKey; - private final LDConfig config; + private final HttpConfiguration httpConfig; private final URI streamUri; private final long initialReconnectDelayMillis; private final FeatureRequestor requestor; @@ -48,13 +48,13 @@ final class StreamProcessor implements UpdateProcessor { ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing public static interface EventSourceCreator { - EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, long initialReconnectDelayMillis, - ConnectionErrorHandler errorHandler, Headers headers); + EventSource createEventSource(EventHandler handler, URI streamUri, long initialReconnectDelayMillis, + ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig); } StreamProcessor( String sdkKey, - LDConfig config, + HttpConfiguration httpConfig, FeatureRequestor requestor, FeatureStore featureStore, EventSourceCreator eventSourceCreator, @@ -63,7 +63,7 @@ EventSource createEventSource(LDConfig config, EventHandler handler, URI streamU ) { this.store = featureStore; this.sdkKey = sdkKey; - this.config = config; // this is no longer the source of truth for streamUri or initialReconnectDelayMillis, but it can affect HTTP behavior + this.httpConfig = httpConfig; this.requestor = requestor; this.eventSourceCreator = eventSourceCreator != null ? eventSourceCreator : new DefaultEventSourceCreator(); this.streamUri = streamUri; @@ -202,11 +202,12 @@ public void onError(Throwable throwable) { } }; - es = eventSourceCreator.createEventSource(config, handler, + es = eventSourceCreator.createEventSource(handler, URI.create(streamUri.toASCIIString() + "/all"), initialReconnectDelayMillis, wrappedConnectionErrorHandler, - headers); + headers, + httpConfig); es.start(); return initFuture; } @@ -252,12 +253,12 @@ public DeleteData() { } } private class DefaultEventSourceCreator implements EventSourceCreator { - public EventSource createEventSource(final LDConfig config, EventHandler handler, URI streamUri, long initialReconnectDelayMillis, - ConnectionErrorHandler errorHandler, Headers headers) { + public EventSource createEventSource(EventHandler handler, URI streamUri, long initialReconnectDelayMillis, + ConnectionErrorHandler errorHandler, Headers headers, final HttpConfiguration httpConfig) { EventSource.Builder builder = new EventSource.Builder(handler, streamUri) .clientBuilderActions(new EventSource.Builder.ClientConfigurer() { public void configure(OkHttpClient.Builder builder) { - configureHttpClientBuilder(config, builder); + configureHttpClientBuilder(httpConfig, builder); } }) .connectionErrorHandler(errorHandler) diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 757aa8cdb..62b243cd5 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -32,7 +32,7 @@ static DateTime jsonPrimitiveToDateTime(LDValue maybeDate) { } } - static void configureHttpClientBuilder(LDConfig config, OkHttpClient.Builder builder) { + static void configureHttpClientBuilder(HttpConfiguration config, OkHttpClient.Builder builder) { builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) .connectTimeout(config.connectTimeout, config.connectTimeoutUnit) .readTimeout(config.socketTimeout, config.socketTimeoutUnit) @@ -51,6 +51,25 @@ static void configureHttpClientBuilder(LDConfig config, OkHttpClient.Builder bui } } +// static void configureHttpClientBuilder(LDConfig config, OkHttpClient.Builder builder) { +// builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) +// .connectTimeout(config.connectTimeout, config.connectTimeoutUnit) +// .readTimeout(config.socketTimeout, config.socketTimeoutUnit) +// .writeTimeout(config.socketTimeout, config.socketTimeoutUnit) +// .retryOnConnectionFailure(false); // we will implement our own retry logic +// +// if (config.sslSocketFactory != null) { +// builder.sslSocketFactory(config.sslSocketFactory, config.trustManager); +// } +// +// if (config.proxy != null) { +// builder.proxy(config.proxy); +// if (config.proxyAuthenticator != null) { +// builder.proxyAuthenticator(config.proxyAuthenticator); +// } +// } +// } + static Request.Builder getRequestBuilder(String sdkKey) { return new Request.Builder() .addHeader("Authorization", sdkKey) diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java new file mode 100644 index 000000000..99dca7b78 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java @@ -0,0 +1,186 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.EventProcessorFactory; + +import java.net.URI; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Contains methods for configuring delivery of analytics events. + *

      + * The SDK normally buffers analytics events and sends them to LaunchDarkly at intervals. If you want + * to customize this behavior, create a builder with {@link Components#sendEvents()}, change its + * properties with the methods of this class, and pass it to {@link com.launchdarkly.client.LDConfig.Builder#events(EventProcessorFactory)}: + *

      
      + *     LDConfig config = new LDConfig.Builder()
      + *         .events(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
      + *         .build();
      + * 
      + *

      + * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link com.launchdarkly.client.LDConfig.Builder#capacity(int)}. + *

      + * Note that this class is abstract; the actual implementation is created by calling {@link Components#sendEvents()}. + * + * @since 4.12.0 + */ +public abstract class EventProcessorBuilder implements EventProcessorFactory { + /** + * The default value for {@link #capacity(int)}. + */ + public static final int DEFAULT_CAPACITY = 10000; + + /** + * The default value for {@link #flushIntervalSeconds(int)}. + */ + public static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 5; + + /** + * The default value for {@link #userKeysCapacity(int)}. + */ + public static final int DEFAULT_USER_KEYS_CAPACITY = 1000; + + /** + * The default value for {@link #userKeysFlushIntervalSeconds(int)}. + */ + public static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; + + protected boolean allAttributesPrivate = false; + protected URI baseUri; + protected int capacity = DEFAULT_CAPACITY; + protected int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; + protected boolean inlineUsersInEvents = false; + protected Set privateAttrNames; + protected int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; + protected int userKeysFlushIntervalSeconds = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; + + /** + * Sets whether or not all optional user attributes should be hidden from LaunchDarkly. + *

      + * If this is {@code true}, all user attribute values (other than the key) will be private, not just + * the attributes specified in {@link #privateAttributeNames(String...)} or on a per-user basis with + * {@link com.launchdarkly.client.LDUser.Builder} methods. By default, it is {@code false}. + * + * @param allAttributesPrivate true if all user attributes should be private + * @return the builder + * @see #privateAttributeNames(String...) + * @see com.launchdarkly.client.LDUser.Builder + */ + public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) { + this.allAttributesPrivate = allAttributesPrivate; + return this; + } + + /** + * Sets a custom base URI for the events service. + *

      + * You will only need to change this value in the following cases: + *

        + *
      • You are using the Relay Proxy. Set + * {@code streamUri} to the base URI of the Relay Proxy instance. + *
      • You are connecting to a test server or a nonstandard endpoint for the LaunchDarkly service. + *
      + * + * @param baseUri the base URI of the events service; null to use the default + * @return the builder + */ + public EventProcessorBuilder baseUri(URI baseUri) { + this.baseUri = baseUri; + return this; + } + + /** + * Set the capacity of the events buffer. + *

      + * The client buffers up to this many events in memory before flushing. If the capacity is exceeded before + * the buffer is flushed (see {@link #flushIntervalSeconds(int)}, events will be discarded. Increasing the + * capacity means that events are less likely to be discarded, at the cost of consuming more memory. + *

      + * The default value is {@link #DEFAULT_CAPACITY}. + * + * @param capacity the capacity of the event buffer + * @return the builder + */ + public EventProcessorBuilder capacity(int capacity) { + this.capacity = capacity; + return this; + } + + /** + * Sets the interval between flushes of the event buffer. + *

      + * Decreasing the flush interval means that the event buffer is less likely to reach capacity. + *

      + * The default value is {@link #DEFAULT_FLUSH_INTERVAL_SECONDS}. + * + * @param flushIntervalSeconds the flush interval in seconds + * @return the builder + */ + public EventProcessorBuilder flushIntervalSeconds(int flushIntervalSeconds) { + this.flushIntervalSeconds = flushIntervalSeconds; + return this; + } + + /** + * Sets whether to include full user details in every analytics event. + *

      + * The default is {@code false}: events will only include the user key, except for one "index" event + * that provides the full details for the user). + * + * @param inlineUsersInEvents true if you want full user details in each event + * @return the builder + */ + public EventProcessorBuilder inlineUsersInEvents(boolean inlineUsersInEvents) { + this.inlineUsersInEvents = inlineUsersInEvents; + return this; + } + + /** + * Marks a set of attribute names as private. + *

      + * Any users sent to LaunchDarkly with this configuration active will have attributes with these + * names removed. This is in addition to any attributes that were marked as private for an + * individual user with {@link com.launchdarkly.client.LDUser.Builder} methods. + * + * @param attributeNames a set of names that will be removed from user data set to LaunchDarkly + * @return the builder + * @see #allAttributesPrivate(boolean) + * @see com.launchdarkly.client.LDUser.Builder + */ + public EventProcessorBuilder privateAttributeNames(String... attributeNames) { + this.privateAttrNames = new HashSet<>(Arrays.asList(attributeNames)); + return this; + } + + /** + * Sets the number of user keys that the event processor can remember at any one time. + *

      + * To avoid sending duplicate user details in analytics events, the SDK maintains a cache of + * recently seen user keys, expiring at an interval set by {@link #userKeysFlushIntervalSeconds(int)}. + *

      + * The default value is {@link #DEFAULT_USER_KEYS_CAPACITY}. + * + * @param userKeysCapacity the maximum number of user keys to remember + * @return the builder + */ + public EventProcessorBuilder userKeysCapacity(int userKeysCapacity) { + this.userKeysCapacity = userKeysCapacity; + return this; + } + + /** + * Sets the interval at which the event processor will reset its cache of known user keys. + *

      + * The default value is {@link #DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS}. + * + * @param userKeysFlushIntervalSeconds the flush interval in seconds + * @return the builder + */ + public EventProcessorBuilder userKeysFlushIntervalSeconds(int userKeysFlushIntervalSeconds) { + this.userKeysFlushIntervalSeconds = userKeysFlushIntervalSeconds; + return this; + } +} diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index 5c63ca819..789f4346d 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -23,15 +23,15 @@ * Builder calls can be chained, for example: * *

      
      - *     LDConfig config = new LDConfig.Builder()
      - *         .dataStore(
      - *             Components.persistentDataStore(
      - *                 Redis.dataStore()
      - *                     .url("redis://my-redis-host")
      - *                     .database(1)
      - *             ).cacheSeconds(15)
      - *         )
      - *         .build();
      +   *     LDConfig config = new LDConfig.Builder()
      +   *         .dataStore(
      +   *             Components.persistentDataStore(
      +   *                 Redis.dataStore()
      +   *                     .url("redis://my-redis-host")
      +   *                     .database(1)
      +   *             ).cacheSeconds(15)
      +   *         )
      +   *         .build();
        * 
      * * @since 4.12.0 diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 05e117819..df85b9dd9 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -3,6 +3,7 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import com.launchdarkly.client.integrations.EventProcessorBuilder; import com.launchdarkly.client.value.LDValue; import org.hamcrest.Matcher; @@ -14,6 +15,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; +import static com.launchdarkly.client.Components.sendEvents; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.hasJsonProperty; @@ -43,16 +45,28 @@ public class DefaultEventProcessorTest { private static final JsonElement filteredUserJson = gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); - + // Note that all of these events depend on the fact that DefaultEventProcessor does a synchronous // flush when it is closed; in this case, it's closed implicitly by the try-with-resources block. + + private EventProcessorBuilder baseConfig(MockWebServer server) { + return sendEvents().baseUri(server.url("").uri()); + } + + private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { + return makeEventProcessor(ec, LDConfig.DEFAULT); + } + + private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, LDConfig config) { + return (DefaultEventProcessor)ec.createEventProcessor(SDK_KEY, config); + } @Test public void identifyEventIsQueued() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); } @@ -67,9 +81,7 @@ public void userIsFilteredInIdentifyEvent() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = baseConfig(server).allAttributesPrivate(true).build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).allAttributesPrivate(true))) { ep.sendEvent(e); } @@ -87,7 +99,7 @@ public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(fe); } @@ -107,9 +119,7 @@ public void userIsFilteredInIndexEvent() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = baseConfig(server).allAttributesPrivate(true).build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).allAttributesPrivate(true))) { ep.sendEvent(fe); } @@ -129,9 +139,7 @@ public void featureEventCanContainInlineUser() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).inlineUsersInEvents(true))) { ep.sendEvent(fe); } @@ -150,9 +158,8 @@ public void userIsFilteredInFeatureEvent() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = baseConfig(server).inlineUsersInEvents(true).allAttributesPrivate(true).build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server) + .inlineUsersInEvents(true).allAttributesPrivate(true))) { ep.sendEvent(fe); } @@ -172,7 +179,7 @@ public void featureEventCanContainReason() throws Exception { EvaluationDetail.fromValue(LDValue.of("value"), 1, reason), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(fe); } @@ -192,9 +199,7 @@ public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTra simpleEvaluation(1, LDValue.of("value")), null); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).inlineUsersInEvents(true))) { ep.sendEvent(fe); } @@ -214,7 +219,7 @@ public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(fe); } @@ -236,7 +241,7 @@ public void eventCanBeBothTrackedAndDebugged() throws Exception { simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(fe); } @@ -263,7 +268,7 @@ public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(resp1, resp2)) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); ep.flush(); @@ -296,7 +301,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(resp1, resp2)) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { // Send and flush an event we don't care about, just to set the last server time ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); @@ -329,7 +334,7 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except simpleEvaluation(1, value), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(fe1); ep.sendEvent(fe2); } @@ -362,7 +367,7 @@ public void nonTrackedEventsAreSummarized() throws Exception { simpleEvaluation(2, value2), default2); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(fe1a); ep.sendEvent(fe1b); ep.sendEvent(fe1c); @@ -393,7 +398,7 @@ public void customEventIsQueuedWithUser() throws Exception { Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, metric); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(ce); } @@ -410,9 +415,7 @@ public void customEventCanContainInlineUser() throws Exception { Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = baseConfig(server).inlineUsersInEvents(true).build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).inlineUsersInEvents(true))) { ep.sendEvent(ce); } @@ -426,9 +429,8 @@ public void userIsFilteredInCustomEvent() throws Exception { Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = baseConfig(server).inlineUsersInEvents(true).allAttributesPrivate(true).build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, config)) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server) + .inlineUsersInEvents(true).allAttributesPrivate(true))) { ep.sendEvent(ce); } @@ -441,7 +443,7 @@ public void closingEventProcessorForcesSynchronousFlush() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); } @@ -452,7 +454,7 @@ public void closingEventProcessorForcesSynchronousFlush() throws Exception { @Test public void nothingIsSentIfThereAreNoEvents() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build()); + DefaultEventProcessor ep = makeEventProcessor(baseConfig(server)); ep.close(); assertEquals(0, server.getRequestCount()); @@ -464,7 +466,7 @@ public void sdkKeyIsSent() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); } @@ -478,7 +480,7 @@ public void eventSchemaIsSent() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); } @@ -492,7 +494,7 @@ public void eventPayloadIdIsSent() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); } @@ -509,7 +511,7 @@ public void eventPayloadIdReusedOnRetry() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(errorResponse, eventsSuccessResponse(), eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); ep.flush(); // Necessary to ensure the retry occurs before the second request for test assertion ordering @@ -570,11 +572,8 @@ public void flushIsRetriedOnceAfter5xxError() throws Exception { @Test public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { - LDConfig config = new LDConfig.Builder() - .eventsURI(serverWithCert.uri()) - .build(); - - try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config)) { + EventProcessorBuilder ec = sendEvents().baseUri(serverWithCert.uri()); + try (DefaultEventProcessor ep = makeEventProcessor(ec)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); @@ -589,12 +588,12 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { @Test public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { + EventProcessorBuilder ec = sendEvents().baseUri(serverWithCert.uri()); LDConfig config = new LDConfig.Builder() - .eventsURI(serverWithCert.uri()) .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); - try (DefaultEventProcessor ep = new DefaultEventProcessor("sdk-key", config)) { + try (DefaultEventProcessor ep = makeEventProcessor(ec, config)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); @@ -610,7 +609,7 @@ private void testUnrecoverableHttpError(int status) throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); } @@ -628,7 +627,7 @@ private void testRecoverableHttpError(int status) throws Exception { // send two errors in a row, because the flush will be retried one time try (MockWebServer server = makeStartedServer(errorResponse, errorResponse, eventsSuccessResponse())) { - try (DefaultEventProcessor ep = new DefaultEventProcessor(SDK_KEY, baseConfig(server).build())) { + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { ep.sendEvent(e); } @@ -641,10 +640,6 @@ private void testRecoverableHttpError(int status) throws Exception { } } - private LDConfig.Builder baseConfig(MockWebServer server) { - return new LDConfig.Builder().eventsURI(server.url("/").uri()); - } - private MockResponse eventsSuccessResponse() { return new MockResponse().setResponseCode(202); } diff --git a/src/test/java/com/launchdarkly/client/EventOutputTest.java b/src/test/java/com/launchdarkly/client/EventOutputTest.java index a1f698270..232258e51 100644 --- a/src/test/java/com/launchdarkly/client/EventOutputTest.java +++ b/src/test/java/com/launchdarkly/client/EventOutputTest.java @@ -37,7 +37,7 @@ public class EventOutputTest { .lastName("last") .name("me") .secondary("s"); - private LDValue userJsonWithAllAttributes = parseValue("{" + + private static final LDValue userJsonWithAllAttributes = parseValue("{" + "\"key\":\"userkey\"," + "\"anonymous\":true," + "\"avatar\":\"http://avatar\"," + @@ -54,21 +54,21 @@ public class EventOutputTest { @Test public void allUserAttributesAreSerialized() throws Exception { testInlineUserSerialization(userBuilderWithAllAttributes.build(), userJsonWithAllAttributes, - new LDConfig.Builder()); + TestUtil.defaultEventsConfig()); } @Test public void unsetUserAttributesAreNotSerialized() throws Exception { LDUser user = new LDUser("userkey"); LDValue userJson = parseValue("{\"key\":\"userkey\"}"); - testInlineUserSerialization(user, userJson, new LDConfig.Builder()); + testInlineUserSerialization(user, userJson, TestUtil.defaultEventsConfig()); } @Test public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { LDUser user = new LDUser.Builder("userkey").name("me").build(); LDValue userJson = parseValue("{\"key\":\"userkey\",\"name\":\"me\"}"); - EventOutputFormatter f = new EventOutputFormatter(new LDConfig.Builder().build()); + EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( new FeatureFlagBuilder("flag").build(), @@ -98,7 +98,7 @@ public void userKeyIsSetInsteadOfUserWhenNotInlined() throws Exception { @Test public void allAttributesPrivateMakesAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); - LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); + EventsConfiguration config = TestUtil.makeEventsConfig(true, false, null); testPrivateAttributes(config, user, attributesThatCanBePrivate); } @@ -106,7 +106,7 @@ public void allAttributesPrivateMakesAttributesPrivate() throws Exception { public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception { LDUser user = userBuilderWithAllAttributes.build(); for (String attrName: attributesThatCanBePrivate) { - LDConfig config = new LDConfig.Builder().privateAttributeNames(attrName).build(); + EventsConfiguration config = TestUtil.makeEventsConfig(false, false, ImmutableSet.of(attrName)); testPrivateAttributes(config, user, attrName); } } @@ -114,7 +114,7 @@ public void globalPrivateAttributeNamesMakeAttributesPrivate() throws Exception @Test public void perUserPrivateAttributesMakeAttributePrivate() throws Exception { LDUser baseUser = userBuilderWithAllAttributes.build(); - LDConfig config = new LDConfig.Builder().build(); + EventsConfiguration config = TestUtil.defaultEventsConfig(); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateAvatar("x").build(), "avatar"); testPrivateAttributes(config, new LDUser.Builder(baseUser).privateCountry("US").build(), "country"); @@ -126,7 +126,7 @@ public void perUserPrivateAttributesMakeAttributePrivate() throws Exception { testPrivateAttributes(config, new LDUser.Builder(baseUser).privateSecondary("x").build(), "secondary"); } - private void testPrivateAttributes(LDConfig config, LDUser user, String... privateAttrNames) throws IOException { + private void testPrivateAttributes(EventsConfiguration config, LDUser user, String... privateAttrNames) throws IOException { EventOutputFormatter f = new EventOutputFormatter(config); Set privateAttrNamesSet = ImmutableSet.copyOf(privateAttrNames); Event.Identify identifyEvent = EventFactory.DEFAULT.newIdentifyEvent(user); @@ -164,7 +164,7 @@ public void featureEventIsSerialized() throws Exception { EventFactory factoryWithReason = eventFactoryWithTimestamp(100000, true); FeatureFlag flag = new FeatureFlagBuilder("flag").version(11).build(); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); + EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); FeatureRequest feWithVariation = factory.newFeatureRequestEvent(flag, user, new EvaluationDetail(EvaluationReason.off(), 1, LDValue.of("flagvalue")), @@ -241,7 +241,7 @@ public void featureEventIsSerialized() throws Exception { public void identifyEventIsSerialized() throws IOException { EventFactory factory = eventFactoryWithTimestamp(100000, false); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); + EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); Event.Identify ie = factory.newIdentifyEvent(user); LDValue ieJson = parseValue("{" + @@ -257,7 +257,7 @@ public void identifyEventIsSerialized() throws IOException { public void customEventIsSerialized() throws IOException { EventFactory factory = eventFactoryWithTimestamp(100000, false); LDUser user = new LDUser.Builder("userkey").name("me").build(); - EventOutputFormatter f = new EventOutputFormatter(LDConfig.DEFAULT); + EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); Event.Custom ceWithoutData = factory.newCustomEvent("customkey", user, LDValue.ofNull(), null); LDValue ceJson1 = parseValue("{" + @@ -323,7 +323,7 @@ public void summaryEventIsSerialized() throws Exception { summary.noteTimestamp(1000); summary.noteTimestamp(1002); - EventOutputFormatter f = new EventOutputFormatter(new LDConfig.Builder().build()); + EventOutputFormatter f = new EventOutputFormatter(TestUtil.defaultEventsConfig()); StringWriter w = new StringWriter(); int count = f.writeOutputEvents(new Event[0], summary, w); assertEquals(1, count); @@ -358,7 +358,7 @@ public void summaryEventIsSerialized() throws Exception { )); } - private LDValue parseValue(String json) { + private static LDValue parseValue(String json) { return gson.fromJson(json, LDValue.class); } @@ -381,9 +381,9 @@ private LDValue getSingleOutputEvent(EventOutputFormatter f, Event event) throws return parseValue(w.toString()).get(0); } - private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, LDConfig.Builder baseConfig) throws IOException { - baseConfig.inlineUsersInEvents(true); - EventOutputFormatter f = new EventOutputFormatter(baseConfig.build()); + private void testInlineUserSerialization(LDUser user, LDValue expectedJsonValue, EventsConfiguration baseConfig) throws IOException { + EventsConfiguration config = TestUtil.makeEventsConfig(baseConfig.allAttributesPrivate, true, baseConfig.privateAttrNames); + EventOutputFormatter f = new EventOutputFormatter(config); Event.FeatureRequest featureEvent = EventFactory.DEFAULT.newFeatureRequestEvent( new FeatureFlagBuilder("flag").build(), diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index 276cbab00..b5517c18f 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -40,7 +40,7 @@ private DefaultFeatureRequestor makeRequestor(MockWebServer server) { private DefaultFeatureRequestor makeRequestor(MockWebServer server, LDConfig config) { URI uri = server.url("").uri(); - return new DefaultFeatureRequestor(sdkKey, config, uri, true); + return new DefaultFeatureRequestor(sdkKey, config.httpConfig, uri, true); } @Test @@ -198,7 +198,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .proxyPort(serverUrl.port()) .build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config, fakeBaseUri, true)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config.httpConfig, fakeBaseUri, true)) { FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index f60f3633b..6ce44a29d 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -6,6 +6,7 @@ import org.junit.Test; +import static com.launchdarkly.client.Components.noEvents; import static com.launchdarkly.client.TestHttpUtil.basePollingConfig; import static com.launchdarkly.client.TestHttpUtil.baseStreamingConfig; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; @@ -34,7 +35,7 @@ public void clientStartsInPollingMode() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(basePollingConfig(server)) - .sendEvents(false) + .events(noEvents()) .build(); try (LDClient client = new LDClient(sdkKey, config)) { @@ -51,7 +52,7 @@ public void clientFailsInPollingModeWith401Error() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(basePollingConfig(server)) - .sendEvents(false) + .events(noEvents()) .build(); try (LDClient client = new LDClient(sdkKey, config)) { @@ -68,7 +69,7 @@ public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(basePollingConfig(serverWithCert.server)) - .sendEvents(false) + .events(noEvents()) .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); @@ -88,7 +89,7 @@ public void clientStartsInStreamingMode() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(baseStreamingConfig(server)) - .sendEvents(false) + .events(noEvents()) .build(); try (LDClient client = new LDClient(sdkKey, config)) { @@ -105,7 +106,7 @@ public void clientFailsInStreamingModeWith401Error() throws Exception { try (MockWebServer server = makeStartedServer(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(baseStreamingConfig(server)) - .sendEvents(false) + .events(noEvents()) .build(); try (LDClient client = new LDClient(sdkKey, config)) { @@ -124,7 +125,7 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { LDConfig config = new LDConfig.Builder() .dataSource(baseStreamingConfig(serverWithCert.server)) - .sendEvents(false) + .events(noEvents()) .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); diff --git a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java index 83321c7a7..98403deb7 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEvaluationTest.java @@ -35,7 +35,7 @@ public class LDClientEvaluationTest { private LDConfig config = new LDConfig.Builder() .dataStore(specificFeatureStore(featureStore)) - .eventProcessor(Components.nullEventProcessor()) + .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); @@ -223,7 +223,7 @@ public void appropriateErrorIfClientNotInitialized() throws Exception { FeatureStore badFeatureStore = new InMemoryFeatureStore(); LDConfig badConfig = new LDConfig.Builder() .dataStore(specificFeatureStore(badFeatureStore)) - .eventProcessor(Components.nullEventProcessor()) + .events(Components.noEvents()) .dataSource(specificUpdateProcessor(failedUpdateProcessor())) .startWaitMillis(0) .build(); @@ -265,7 +265,7 @@ public void appropriateErrorForUnexpectedException() throws Exception { FeatureStore badFeatureStore = featureStoreThatThrowsException(exception); LDConfig badConfig = new LDConfig.Builder() .dataStore(specificFeatureStore(badFeatureStore)) - .eventProcessor(Components.nullEventProcessor()) + .events(Components.noEvents()) .dataSource(Components.externalUpdatesOnly()) .build(); try (LDClientInterface badClient = new LDClient("SDK_KEY", badConfig)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEventTest.java b/src/test/java/com/launchdarkly/client/LDClientEventTest.java index 9e77aa0ec..d15453fab 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEventTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEventTest.java @@ -30,7 +30,7 @@ public class LDClientEventTest { private TestUtil.TestEventProcessor eventSink = new TestUtil.TestEventProcessor(); private LDConfig config = new LDConfig.Builder() .dataStore(specificFeatureStore(featureStore)) - .eventProcessor(specificEventProcessor(eventSink)) + .events(specificEventProcessor(eventSink)) .dataSource(Components.externalUpdatesOnly()) .build(); private LDClientInterface client = new LDClient("SDK_KEY", config); diff --git a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java index 20819fee0..c725f25ef 100644 --- a/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientOfflineTest.java @@ -37,7 +37,7 @@ public void offlineClientHasNullEventProcessor() throws IOException { .offline(true) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(EventProcessor.NullEventProcessor.class, client.eventProcessor.getClass()); + assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); } } diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 3c83cdeb9..7499b2bc1 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -83,6 +83,42 @@ public void constructorThrowsExceptionForNullConfig() throws Exception { } } + @Test + public void clientHasDefaultEventProcessorWithDefaultConfig() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .startWaitMillis(0) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void clientHasDefaultEventProcessorWithSendEvents() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.sendEvents()) + .startWaitMillis(0) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @Test + public void clientHasNullEventProcessorWithNoEvents() throws Exception { + LDConfig config = new LDConfig.Builder() + .dataSource(Components.externalUpdatesOnly()) + .events(Components.noEvents()) + .startWaitMillis(0) + .build(); + try (LDClient client = new LDClient("SDK_KEY", config)) { + assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); + } + } + + @SuppressWarnings("deprecation") @Test public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception { LDConfig config = new LDConfig.Builder() @@ -94,7 +130,8 @@ public void clientHasDefaultEventProcessorIfSendEventsIsTrue() throws Exception assertEquals(DefaultEventProcessor.class, client.eventProcessor.getClass()); } } - + + @SuppressWarnings("deprecation") @Test public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException { LDConfig config = new LDConfig.Builder() @@ -103,7 +140,7 @@ public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException .sendEvents(false) .build(); try (LDClient client = new LDClient("SDK_KEY", config)) { - assertEquals(EventProcessor.NullEventProcessor.class, client.eventProcessor.getClass()); + assertEquals(Components.NullEventProcessor.class, client.eventProcessor.getClass()); } } @@ -292,7 +329,7 @@ public void dataSetIsPassedToFeatureStoreInCorrectOrder() throws Exception { LDConfig.Builder config = new LDConfig.Builder() .dataSource(updateProcessorWithData(DEPENDENCY_ORDERING_TEST_DATA)) .dataStore(specificFeatureStore(store)) - .sendEvents(false); + .events(Components.noEvents()); client = new LDClient("SDK_KEY", config.build()); Map, Map> dataMap = captureData.getValue(); @@ -337,7 +374,7 @@ private void expectEventsSent(int count) { private LDClientInterface createMockClient(LDConfig.Builder config) { config.dataSource(TestUtil.specificUpdateProcessor(updateProcessor)); - config.eventProcessor(TestUtil.specificEventProcessor(eventProcessor)); + config.events(TestUtil.specificEventProcessor(eventProcessor)); return new LDClient("SDK_KEY", config.build()); } diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index fbbc881d5..c33239b18 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -14,20 +14,20 @@ public class LDConfigTest { @Test public void testNoProxyConfigured() { LDConfig config = new LDConfig.Builder().build(); - assertNull(config.proxy); - assertNull(config.proxyAuthenticator); + assertNull(config.httpConfig.proxy); + assertNull(config.httpConfig.proxyAuthenticator); } @Test public void testOnlyProxyHostConfiguredIsNull() { LDConfig config = new LDConfig.Builder().proxyHost("bla").build(); - assertNull(config.proxy); + assertNull(config.httpConfig.proxy); } @Test public void testOnlyProxyPortConfiguredHasPortAndDefaultHost() { LDConfig config = new LDConfig.Builder().proxyPort(1234).build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.proxy); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.proxy); } @Test public void testProxy() { @@ -35,7 +35,7 @@ public void testProxy() { .proxyHost("localhost2") .proxyPort(4444) .build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost2", 4444)), config.proxy); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost2", 4444)), config.httpConfig.proxy); } @Test @@ -46,8 +46,8 @@ public void testProxyAuth() { .proxyUsername("proxyUser") .proxyPassword("proxyPassword") .build(); - assertNotNull(config.proxy); - assertNotNull(config.proxyAuthenticator); + assertNotNull(config.httpConfig.proxy); + assertNotNull(config.httpConfig.proxyAuthenticator); } @Test @@ -57,28 +57,28 @@ public void testProxyAuthPartialConfig() { .proxyPort(4444) .proxyUsername("proxyUser") .build(); - assertNotNull(config.proxy); - assertNull(config.proxyAuthenticator); + assertNotNull(config.httpConfig.proxy); + assertNull(config.httpConfig.proxyAuthenticator); config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) .proxyPassword("proxyPassword") .build(); - assertNotNull(config.proxy); - assertNull(config.proxyAuthenticator); + assertNotNull(config.httpConfig.proxy); + assertNull(config.httpConfig.proxyAuthenticator); } + @SuppressWarnings("deprecation") @Test public void testMinimumPollingIntervalIsEnforcedProperly(){ - @SuppressWarnings("deprecation") LDConfig config = new LDConfig.Builder().pollingIntervalMillis(10L).build(); assertEquals(30000L, config.deprecatedPollingIntervalMillis); } + @SuppressWarnings("deprecation") @Test public void testPollingIntervalIsEnforcedProperly(){ - @SuppressWarnings("deprecation") LDConfig config = new LDConfig.Builder().pollingIntervalMillis(30001L).build(); assertEquals(30001L, config.deprecatedPollingIntervalMillis); } @@ -86,12 +86,13 @@ public void testPollingIntervalIsEnforcedProperly(){ @Test public void testSendEventsDefaultsToTrue() { LDConfig config = new LDConfig.Builder().build(); - assertEquals(true, config.sendEvents); + assertEquals(true, config.deprecatedSendEvents); } + @SuppressWarnings("deprecation") @Test public void testSendEventsCanBeSetToFalse() { LDConfig config = new LDConfig.Builder().sendEvents(false).build(); - assertEquals(false, config.sendEvents); + assertEquals(false, config.deprecatedSendEvents); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index ef0471525..c4c6adeb8 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -18,10 +18,14 @@ import java.util.Map; import java.util.Set; +import static com.launchdarkly.client.JsonHelpers.gsonInstance; +import static com.launchdarkly.client.JsonHelpers.gsonInstanceForEventsSerialization; +import static com.launchdarkly.client.TestUtil.defaultEventsConfig; 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.makeEventsConfig; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -293,8 +297,8 @@ public void canSetPrivateDeprecatedCustomJsonValue() { @Test public void testAllPropertiesInDefaultEncoding() { for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = defaultGson.fromJson(e.getValue(), JsonElement.class); - JsonElement actual = defaultGson.toJsonTree(e.getKey()); + JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); + JsonElement actual = gsonInstance().toJsonTree(e.getKey()); assertEquals(expected, actual); } } @@ -302,8 +306,8 @@ public void testAllPropertiesInDefaultEncoding() { @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()); + JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); + JsonElement actual = gsonInstance().toJsonTree(e.getKey()); assertEquals(expected, actual); } } @@ -343,7 +347,8 @@ public void defaultJsonEncodingHasPrivateAttributeNames() { @Test public void privateAttributeEncodingRedactsAllPrivateAttributes() { - LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); + EventsConfiguration config = makeEventsConfig(true, false, null); + @SuppressWarnings("deprecation") LDUser user = new LDUser.Builder("userkey") .secondary("s") .ip("i") @@ -358,7 +363,7 @@ public void privateAttributeEncodingRedactsAllPrivateAttributes() { .build(); Set redacted = ImmutableSet.of("secondary", "ip", "email", "name", "avatar", "firstName", "lastName", "country", "thing"); - JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); assertEquals("userkey", o.get("key").getAsString()); assertEquals(true, o.get("anonymous").getAsBoolean()); for (String attr: redacted) { @@ -377,7 +382,7 @@ public void privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { .privateCustom("foo", 42) .build(); - JsonObject o = LDConfig.DEFAULT.gson.toJsonTree(user).getAsJsonObject(); + JsonObject o = gsonInstanceForEventsSerialization(defaultEventsConfig()).toJsonTree(user).getAsJsonObject(); assertEquals("e", o.get("email").getAsString()); assertNull(o.get("name")); assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); @@ -387,7 +392,7 @@ public void privateAttributeEncodingRedactsSpecificPerUserPrivateAttributes() { @Test public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { - LDConfig config = new LDConfig.Builder().privateAttributeNames("name", "foo").build(); + EventsConfiguration config = makeEventsConfig(false, false, ImmutableSet.of("name", "foo")); LDUser user = new LDUser.Builder("userkey") .email("e") .name("n") @@ -395,7 +400,7 @@ public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { .custom("foo", 42) .build(); - JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); assertEquals("e", o.get("email").getAsString()); assertNull(o.get("name")); assertEquals(43, o.get("custom").getAsJsonObject().get("bar").getAsInt()); @@ -405,10 +410,10 @@ public void privateAttributeEncodingRedactsSpecificGlobalPrivateAttributes() { @Test public void privateAttributeEncodingWorksForMinimalUser() { - LDConfig config = new LDConfig.Builder().allAttributesPrivate(true).build(); + EventsConfiguration config = makeEventsConfig(true, false, null); LDUser user = new LDUser("userkey"); - JsonObject o = config.gson.toJsonTree(user).getAsJsonObject(); + JsonObject o = gsonInstanceForEventsSerialization(config).toJsonTree(user).getAsJsonObject(); JsonObject expected = new JsonObject(); expected.addProperty("key", "userkey"); assertEquals(expected, o); @@ -462,7 +467,7 @@ public void canAddCustomAttrWithListOfStrings() { .customString("foo", ImmutableList.of("a", "b")) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), js("b")); - JsonObject jo = LDConfig.DEFAULT.gson.toJsonTree(user).getAsJsonObject(); + JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } @@ -472,7 +477,7 @@ public void canAddCustomAttrWithListOfNumbers() { .customNumber("foo", ImmutableList.of(new Integer(1), new Double(2))) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", jint(1), jdouble(2)); - JsonObject jo = LDConfig.DEFAULT.gson.toJsonTree(user).getAsJsonObject(); + JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } @@ -482,7 +487,7 @@ public void canAddCustomAttrWithListOfMixedValues() { .customValues("foo", ImmutableList.of(js("a"), jint(1), jbool(true))) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), jint(1), jbool(true)); - JsonObject jo = LDConfig.DEFAULT.gson.toJsonTree(user).getAsJsonObject(); + JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 66608462b..57e65be8e 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -434,7 +434,7 @@ private void testRecoverableHttpError(int status) throws Exception { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config, mockRequestor, featureStore, new StubEventSourceCreator(), + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, new StubEventSourceCreator(), streamUri, config.deprecatedReconnectTimeMs); } @@ -443,7 +443,7 @@ private StreamProcessor createStreamProcessor(URI streamUri) { } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config, mockRequestor, featureStore, null, + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, null, streamUri, config.deprecatedReconnectTimeMs); } @@ -474,8 +474,8 @@ private void assertSegmentInStore(Segment segment) { } private class StubEventSourceCreator implements StreamProcessor.EventSourceCreator { - public EventSource createEventSource(LDConfig config, EventHandler handler, URI streamUri, - long initialReconnectDelay, ConnectionErrorHandler errorHandler, Headers headers) { + public EventSource createEventSource(EventHandler handler, URI streamUri, + long initialReconnectDelay, ConnectionErrorHandler errorHandler, Headers headers, HttpConfiguration httpConfig) { StreamProcessorTest.this.eventHandler = handler; StreamProcessorTest.this.actualStreamUri = streamUri; StreamProcessorTest.this.errorHandler = errorHandler; diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index cd963461a..7f54030b4 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -18,6 +18,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.Future; import static org.hamcrest.Matchers.equalTo; @@ -275,4 +276,18 @@ protected boolean matchesSafely(JsonElement item, Description mismatchDescriptio } }; } + + static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, + Set privateAttrNames) { + return new EventsConfiguration( + allAttributesPrivate, + 0, null, 0, + inlineUsersInEvents, + privateAttrNames, + 0, 0, 0); + } + + static EventsConfiguration defaultEventsConfig() { + return makeEventsConfig(false, false, null); + } } diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java index a6423aee1..400660247 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -85,7 +85,7 @@ public void testDateTimeConversionInvalidString() { public void testConnectTimeoutSpecifiedInSeconds() { LDConfig config = new LDConfig.Builder().connectTimeout(3).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config, httpBuilder); + configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); try { assertEquals(3000, httpClient.connectTimeoutMillis()); @@ -98,7 +98,7 @@ public void testConnectTimeoutSpecifiedInSeconds() { public void testConnectTimeoutSpecifiedInMilliseconds() { LDConfig config = new LDConfig.Builder().connectTimeoutMillis(3000).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config, httpBuilder); + configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); try { assertEquals(3000, httpClient.connectTimeoutMillis()); @@ -111,7 +111,7 @@ public void testConnectTimeoutSpecifiedInMilliseconds() { public void testSocketTimeoutSpecifiedInSeconds() { LDConfig config = new LDConfig.Builder().socketTimeout(3).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config, httpBuilder); + configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); try { assertEquals(3000, httpClient.readTimeoutMillis()); @@ -124,7 +124,7 @@ public void testSocketTimeoutSpecifiedInSeconds() { public void testSocketTimeoutSpecifiedInMilliseconds() { LDConfig config = new LDConfig.Builder().socketTimeoutMillis(3000).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config, httpBuilder); + configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); try { assertEquals(3000, httpClient.readTimeoutMillis()); From 85d0c7a82980c77a3e6310b6f8bd257dc1a90b88 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 14:45:18 -0800 Subject: [PATCH 261/327] doc comment copyedit --- src/main/java/com/launchdarkly/client/Components.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 3efdafc13..9dc560fc1 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -206,7 +206,7 @@ public static UpdateProcessorFactory defaultUpdateProcessor() { } /** - * Returns a configuration object that disables connecting for feature flag updates. + * Returns a configuration object that disables a direct connection with LaunchDarkly for feature flag updates. *

      * Passing this to {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)} causes the SDK * not to retrieve feature flag data from LaunchDarkly, regardless of any other configuration. From 53f377f7307de66e727cc8bd176da01a48e99738 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 14:53:12 -0800 Subject: [PATCH 262/327] misc cleanup --- .../com/launchdarkly/client/Components.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index a2819b79b..f0c775581 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -110,11 +110,11 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * The default configuration has events enabled with default settings. If you want to * customize this behavior, call this method to obtain a builder, change its properties * with the {@link EventProcessorBuilder} properties, and pass it to {@link LDConfig.Builder#events(EventProcessorFactory)}: - *

      +   * 
      
          *     LDConfig config = new LDConfig.Builder()
      -   *         .event(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
      +   *         .events(Components.sendEvents().capacity(5000).flushIntervalSeconds(2))
          *         .build();
      -   * 
      + *
      * To completely disable sending analytics events, use {@link #noEvents()} instead. * * @return a builder for setting streaming connection properties @@ -122,7 +122,7 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * @since 4.12.0 */ public static EventProcessorBuilder sendEvents() { - return new EventsConfigBuilderImpl(); + return new EventProcessorBuilderImpl(); } /** @@ -136,6 +136,7 @@ public static EventProcessorBuilder sendEvents() { * LaunchDarkly. *
    • Otherwise, it will send events, using the properties set by the deprecated events configuration * methods such as {@link LDConfig.Builder#capacity(int)}. + *
    * * @return a factory object * @deprecated Use {@link #sendEvents()} or {@link #noEvents}. @@ -150,11 +151,11 @@ public static EventProcessorFactory defaultEventProcessor() { *

    * Passing this to {@link LDConfig.Builder#events(EventProcessorFactory)} causes the SDK * to discard all analytics events and not send them to LaunchDarkly, regardless of any other configuration. - *

    +   * 
    
        *     LDConfig config = new LDConfig.Builder()
    -   *         .eventProcessor(Components.noEvents())
    +   *         .events(Components.noEvents())
        *         .build();
    -   * 
    + *
    * * @return a factory object * @see LDConfig.Builder#events(EventProcessorFactory) @@ -456,7 +457,7 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea } } - private static final class EventsConfigBuilderImpl extends EventProcessorBuilder { + private static final class EventProcessorBuilderImpl extends EventProcessorBuilder { @Override public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { if (config.offline) { From b87ce53ae4ab3d2f5debd9bfaca00738d4c01846 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Jan 2020 16:23:58 -0800 Subject: [PATCH 263/327] better comment --- src/main/java/com/launchdarkly/client/Components.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index f0c775581..4fcd6a54a 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -13,7 +13,16 @@ import static com.google.common.util.concurrent.Futures.immediateFuture; /** - * Provides factories for the standard implementations of LaunchDarkly component interfaces. + * Provides configurable factories for the standard implementations of LaunchDarkly component interfaces. + *

    + * Some of the configuration options in {@link LDConfig.Builder} affect the entire SDK, but others are + * specific to one area of functionality, such as how the SDK receives feature flag updates or processes + * analytics events. For the latter, the standard way to specify a configuration is to call one of the + * static methods in {@link Components} (such as {@link #streamingDataSource()}), apply any desired + * configuration change to the object that that method returns (such as {@link StreamingDataSourceBuilder#initialReconnectDelayMillis(long)}, + * and then use the corresponding method in {@link LDConfig.Builder} (such as {@link LDConfig.Builder#dataSource(UpdateProcessorFactory)}) + * to use that configured component in the SDK. + * * @since 4.0.0 */ public abstract class Components { From 38c57ecd530b1142e51732546829b529b4a94408 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Jan 2020 14:58:14 -0800 Subject: [PATCH 264/327] new dataStoreType behavior in diagnostic events --- .../com/launchdarkly/client/Components.java | 11 +- .../launchdarkly/client/DiagnosticEvent.java | 124 +++++++++-------- .../client/InMemoryFeatureStore.java | 10 +- .../client/RedisFeatureStoreBuilder.java | 9 +- .../PersistentDataStoreBuilder.java | 12 +- .../integrations/RedisDataStoreImpl.java | 9 +- .../interfaces/DiagnosticDescription.java | 28 ++++ .../client/DiagnosticEventTest.java | 131 +++++++++++------- 8 files changed, 219 insertions(+), 115 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 46bc08a33..4ed26c32d 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -1,7 +1,9 @@ package com.launchdarkly.client; import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; +import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; +import com.launchdarkly.client.value.LDValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -169,11 +171,16 @@ public static UpdateProcessorFactory nullUpdateProcessor() { return nullUpdateProcessorFactory; } - private static final class InMemoryFeatureStoreFactory implements FeatureStoreFactory { + private static final class InMemoryFeatureStoreFactory implements FeatureStoreFactory, DiagnosticDescription { @Override public FeatureStore createFeatureStore() { return new InMemoryFeatureStore(); } + + @Override + public LDValue describeConfiguration() { + return LDValue.of("memory"); + } } private static final class DefaultEventProcessorFactory implements EventProcessorFactoryWithDiagnostics { @@ -202,12 +209,12 @@ private static final class DefaultUpdateProcessorFactory implements UpdateProces // Note, logger uses LDClient class name for backward compatibility private static final Logger logger = LoggerFactory.getLogger(LDClient.class); - @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore) { return createUpdateProcessor(sdkKey, config, featureStore, null); } + @SuppressWarnings("deprecation") @Override public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, FeatureStore featureStore, DiagnosticAccumulator diagnosticAccumulator) { diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 45f4892b6..057e88b63 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -1,9 +1,12 @@ package com.launchdarkly.client; +import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.value.LDValue; +import com.launchdarkly.client.value.ObjectBuilder; + import java.util.List; class DiagnosticEvent { - final String kind; final long creationDate; final DiagnosticId id; @@ -46,74 +49,79 @@ static class Statistics extends DiagnosticEvent { } static class Init extends DiagnosticEvent { - + // Arbitrary limit to prevent custom components from injecting too much data into the configuration object + private static final int MAX_COMPONENT_PROPERTY_LENGTH = 100; + final DiagnosticSdk sdk; - final DiagnosticConfiguration configuration; + final LDValue configuration; final DiagnosticPlatform platform = new DiagnosticPlatform(); Init(long creationDate, DiagnosticId diagnosticId, LDConfig config) { super("diagnostic-init", creationDate, diagnosticId); this.sdk = new DiagnosticSdk(config); - this.configuration = new DiagnosticConfiguration(config); + this.configuration = getConfigurationData(config); } - @SuppressWarnings("unused") // fields are for JSON serialization only - static class DiagnosticConfiguration { - private final boolean customBaseURI; - private final boolean customEventsURI; - private final boolean customStreamURI; - private final int eventsCapacity; - private final int connectTimeoutMillis; - private final int socketTimeoutMillis; - private final long eventsFlushIntervalMillis; - private final boolean usingProxy; - private final boolean usingProxyAuthenticator; - private final boolean streamingDisabled; - private final boolean usingRelayDaemon; - private final boolean offline; - private final boolean allAttributesPrivate; - private final long pollingIntervalMillis; - private final long startWaitMillis; - private final int samplingInterval; - private final long reconnectTimeMillis; - private final int userKeysCapacity; - private final long userKeysFlushIntervalMillis; - private final boolean inlineUsersInEvents; - private final int diagnosticRecordingIntervalMillis; - private final String featureStore; - - DiagnosticConfiguration(LDConfig config) { - this.customBaseURI = !(LDConfig.DEFAULT_BASE_URI.equals(config.baseURI)); - this.customEventsURI = !(LDConfig.DEFAULT_EVENTS_URI.equals(config.eventsURI)); - this.customStreamURI = !(LDConfig.DEFAULT_STREAM_URI.equals(config.streamURI)); - this.eventsCapacity = config.capacity; - this.connectTimeoutMillis = (int)config.connectTimeoutUnit.toMillis(config.connectTimeout); - this.socketTimeoutMillis = (int)config.socketTimeoutUnit.toMillis(config.socketTimeout); - this.eventsFlushIntervalMillis = config.flushInterval * 1000; - this.usingProxy = config.proxy != null; - this.usingProxyAuthenticator = config.proxyAuthenticator != null; - this.streamingDisabled = !config.stream; - this.usingRelayDaemon = config.useLdd; - this.offline = config.offline; - this.allAttributesPrivate = config.allAttributesPrivate; - this.pollingIntervalMillis = config.pollingIntervalMillis; - this.startWaitMillis = config.startWaitMillis; - this.samplingInterval = config.samplingInterval; - this.reconnectTimeMillis = config.reconnectTimeMs; - this.userKeysCapacity = config.userKeysCapacity; - this.userKeysFlushIntervalMillis = config.userKeysFlushInterval * 1000; - this.inlineUsersInEvents = config.inlineUsersInEvents; - this.diagnosticRecordingIntervalMillis = config.diagnosticRecordingIntervalMillis; - if (config.deprecatedFeatureStore != null) { - this.featureStore = config.deprecatedFeatureStore.getClass().getSimpleName(); - } else if (config.dataStoreFactory != null) { - this.featureStore = config.dataStoreFactory.getClass().getSimpleName(); - } else { - this.featureStore = null; + static LDValue getConfigurationData(LDConfig config) { + ObjectBuilder builder = LDValue.buildObject(); + builder.put("customBaseURI", !(LDConfig.DEFAULT_BASE_URI.equals(config.baseURI))); + builder.put("customEventsURI", !(LDConfig.DEFAULT_EVENTS_URI.equals(config.eventsURI))); + builder.put("customStreamURI", !(LDConfig.DEFAULT_STREAM_URI.equals(config.streamURI))); + builder.put("eventsCapacity", config.capacity); + builder.put("connectTimeoutMillis", config.connectTimeoutUnit.toMillis(config.connectTimeout)); + builder.put("socketTimeoutMillis", config.socketTimeoutUnit.toMillis(config.socketTimeout)); + builder.put("eventsFlushIntervalMillis", config.flushInterval * 1000); + builder.put("usingProxy", config.proxy != null); + builder.put("usingProxyAuthenticator", config.proxyAuthenticator != null); + builder.put("streamingDisabled", !config.stream); + builder.put("usingRelayDaemon", config.useLdd); + builder.put("offline", config.offline); + builder.put("allAttributesPrivate", config.allAttributesPrivate); + builder.put("pollingIntervalMillis", config.pollingIntervalMillis); + builder.put("startWaitMillis", config.startWaitMillis); + builder.put("samplingInterval", config.samplingInterval); + builder.put("reconnectTimeMillis", config.reconnectTimeMs); + builder.put("userKeysCapacity", config.userKeysCapacity); + builder.put("userKeysFlushIntervalMillis", config.userKeysFlushInterval * 1000); + builder.put("inlineUsersInEvents", config.inlineUsersInEvents); + builder.put("diagnosticRecordingIntervalMillis", config.diagnosticRecordingIntervalMillis); + mergeComponentProperties(builder, config.deprecatedFeatureStore, "dataStore"); + mergeComponentProperties(builder, config.dataStoreFactory, "dataStore"); + return builder.build(); + } + + // Attempts to add relevant configuration properties, if any, from a customizable component: + // - If the component does not implement DiagnosticDescription, set the defaultPropertyName property to its class name. + // - If it does implement DiagnosticDescription, call its describeConfiguration() method to get a value. + // Currently the only supported value is a string; the defaultPropertyName property will be set to this. + // In the future, we will support JSON objects so that our own components can report properties that are + // not in LDConfig. + private static void mergeComponentProperties(ObjectBuilder builder, Object component, String defaultPropertyName) { + if (component == null) { + return; + } + if (!(component instanceof DiagnosticDescription)) { + if (defaultPropertyName != null) { + builder.put(defaultPropertyName, validateSimpleValue(LDValue.of(component.getClass().getSimpleName()))); } + return; + } + LDValue componentDesc = validateSimpleValue(((DiagnosticDescription)component).describeConfiguration()); + if (!componentDesc.isNull() && defaultPropertyName != null) { + builder.put(defaultPropertyName, componentDesc); } } - + + private static LDValue validateSimpleValue(LDValue value) { + if (value != null && value.isString()) { + if (value.stringValue().length() > MAX_COMPONENT_PROPERTY_LENGTH) { + return LDValue.of(value.stringValue().substring(0, MAX_COMPONENT_PROPERTY_LENGTH)); + } + return value; + } + return LDValue.ofNull(); + } + static class DiagnosticSdk { final String name = "java-server-sdk"; final String version = LDClient.CLIENT_VERSION; diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index 05ad4bbb2..ba6015d40 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -1,5 +1,8 @@ package com.launchdarkly.client; +import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.value.LDValue; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,7 +15,7 @@ * A thread-safe, versioned store for feature flags and related data based on a * {@link HashMap}. This is the default implementation of {@link FeatureStore}. */ -public class InMemoryFeatureStore implements FeatureStore { +public class InMemoryFeatureStore implements FeatureStore, DiagnosticDescription { private static final Logger logger = LoggerFactory.getLogger(InMemoryFeatureStore.class); private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); @@ -137,4 +140,9 @@ public boolean initialized() { public void close() throws IOException { return; } + + @Override + public LDValue describeConfiguration() { + return LDValue.of("memory"); + } } diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index c447da57c..a84cc4849 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -4,6 +4,8 @@ import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; import com.launchdarkly.client.integrations.Redis; import com.launchdarkly.client.integrations.RedisDataStoreBuilder; +import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.value.LDValue; import java.net.URI; import java.net.URISyntaxException; @@ -20,7 +22,7 @@ * @deprecated Use {@link com.launchdarkly.client.integrations.Redis#dataStore()} */ @Deprecated -public final class RedisFeatureStoreBuilder implements FeatureStoreFactory { +public final class RedisFeatureStoreBuilder implements FeatureStoreFactory, DiagnosticDescription { /** * The default value for the Redis URI: {@code redis://localhost:6379} * @since 4.0.0 @@ -281,4 +283,9 @@ public RedisFeatureStore build() { public RedisFeatureStore createFeatureStore() { return build(); } + + @Override + public LDValue describeConfiguration() { + return LDValue.of("Redis"); + } } diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java index 43a1b42b3..c4861a22f 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java @@ -3,9 +3,11 @@ import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.FeatureStoreFactory; +import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; import com.launchdarkly.client.utils.CachingStoreWrapper; import com.launchdarkly.client.utils.FeatureStoreCore; +import com.launchdarkly.client.value.LDValue; import java.util.concurrent.TimeUnit; @@ -36,7 +38,7 @@ * @since 4.11.0 */ @SuppressWarnings("deprecation") -public final class PersistentDataStoreBuilder implements FeatureStoreFactory { +public final class PersistentDataStoreBuilder implements FeatureStoreFactory, DiagnosticDescription { /** * The default value for the cache TTL. */ @@ -213,4 +215,12 @@ public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { this.cacheMonitor = cacheMonitor; return this; } + + @Override + public LDValue describeConfiguration() { + if (persistentDataStoreFactory instanceof DiagnosticDescription) { + return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(); + } + return LDValue.of("?"); + } } diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java index 24e3968b7..9e1e9c656 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreImpl.java @@ -3,7 +3,9 @@ import com.google.common.annotations.VisibleForTesting; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.utils.FeatureStoreCore; +import com.launchdarkly.client.value.LDValue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,7 +24,7 @@ import redis.clients.jedis.Transaction; import redis.clients.util.JedisURIHelper; -class RedisDataStoreImpl implements FeatureStoreCore { +class RedisDataStoreImpl implements FeatureStoreCore, DiagnosticDescription { private static final Logger logger = LoggerFactory.getLogger(RedisDataStoreImpl.class); private final JedisPool pool; @@ -193,4 +195,9 @@ private T getRedis(VersionedDataKind kind, String k static interface UpdateListener { void aboutToUpdate(String baseKey, String itemKey); } + + @Override + public LDValue describeConfiguration() { + return LDValue.of("Redis"); + } } diff --git a/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java b/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java new file mode 100644 index 000000000..840781d65 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/DiagnosticDescription.java @@ -0,0 +1,28 @@ +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.value.LDValue; + +/** + * Optional interface for components to describe their own configuration. + *

    + * The SDK uses a simplified JSON representation of its configuration when recording diagnostics data. + * Any class that implements {@link com.launchdarkly.client.FeatureStoreFactory}, + * {@link com.launchdarkly.client.UpdateProcessorFactory}, {@link com.launchdarkly.client.EventProcessorFactory}, + * or {@link com.launchdarkly.client.interfaces.PersistentDataStoreFactory} may choose to contribute + * values to this representation, although the SDK may or may not use them. For components that do not + * implement this interface, the SDK may instead describe them using {@code getClass().getSimpleName()}. + *

    + * The {@link #describeConfiguration()} method should return either null or a simple JSON value that + * describes the basic nature of this component implementation (e.g. "Redis"). Currently the only + * supported JSON value type is {@link com.launchdarkly.client.value.LDValueType#STRING}. Values over + * 100 characters will be truncated. + * + * @since 4.12.0 + */ +public interface DiagnosticDescription { + /** + * Used internally by the SDK to inspect the configuration. + * @return an {@link LDValue} or null + */ + LDValue describeConfiguration(); +} diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index 94470ca65..23f702216 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -3,6 +3,8 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.value.LDValue; import org.junit.Test; @@ -14,6 +16,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +@SuppressWarnings("javadoc") public class DiagnosticEventTest { private static Gson gson = new Gson(); @@ -47,31 +50,31 @@ public void testSerialization() { @Test public void testDefaultDiagnosticConfiguration() { LDConfig ldConfig = new LDConfig.Builder().build(); - DiagnosticEvent.Init.DiagnosticConfiguration diagnosticConfiguration = new DiagnosticEvent.Init.DiagnosticConfiguration(ldConfig); - JsonObject diagnosticJson = new Gson().toJsonTree(diagnosticConfiguration).getAsJsonObject(); - JsonObject expected = new JsonObject(); - expected.addProperty("allAttributesPrivate", false); - expected.addProperty("connectTimeoutMillis", 2_000); - expected.addProperty("customBaseURI", false); - expected.addProperty("customEventsURI", false); - expected.addProperty("customStreamURI", false); - expected.addProperty("diagnosticRecordingIntervalMillis", 900_000); - expected.addProperty("eventsCapacity", 10_000); - expected.addProperty("eventsFlushIntervalMillis",5_000); - expected.addProperty("featureStore", "InMemoryFeatureStoreFactory"); - expected.addProperty("inlineUsersInEvents", false); - expected.addProperty("offline", false); - expected.addProperty("pollingIntervalMillis", 30_000); - expected.addProperty("reconnectTimeMillis", 1_000); - expected.addProperty("samplingInterval", 0); - expected.addProperty("socketTimeoutMillis", 10_000); - expected.addProperty("startWaitMillis", 5_000); - expected.addProperty("streamingDisabled", false); - expected.addProperty("userKeysCapacity", 1_000); - expected.addProperty("userKeysFlushIntervalMillis", 300_000); - expected.addProperty("usingProxy", false); - expected.addProperty("usingProxyAuthenticator", false); - expected.addProperty("usingRelayDaemon", false); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue expected = LDValue.buildObject() + .put("allAttributesPrivate", false) + .put("connectTimeoutMillis", 2_000) + .put("customBaseURI", false) + .put("customEventsURI", false) + .put("customStreamURI", false) + .put("dataStore", "memory") + .put("diagnosticRecordingIntervalMillis", 900_000) + .put("eventsCapacity", 10_000) + .put("eventsFlushIntervalMillis",5_000) + .put("inlineUsersInEvents", false) + .put("offline", false) + .put("pollingIntervalMillis", 30_000) + .put("reconnectTimeMillis", 1_000) + .put("samplingInterval", 0) + .put("socketTimeoutMillis", 10_000) + .put("startWaitMillis", 5_000) + .put("streamingDisabled", false) + .put("userKeysCapacity", 1_000) + .put("userKeysFlushIntervalMillis", 300_000) + .put("usingProxy", false) + .put("usingProxyAuthenticator", false) + .put("usingRelayDaemon", false) + .build(); assertEquals(expected, diagnosticJson); } @@ -89,7 +92,7 @@ public void testCustomDiagnosticConfiguration() { .sendEvents(false) .capacity(20_000) .flushInterval(10) - .featureStoreFactory(Components.redisFeatureStore()) + .dataStore(Components.redisFeatureStore()) .inlineUsersInEvents(true) .offline(true) .pollingIntervalMillis(60_000) @@ -106,33 +109,59 @@ public void testCustomDiagnosticConfiguration() { .useLdd(true) .build(); - DiagnosticEvent.Init.DiagnosticConfiguration diagnosticConfiguration = new DiagnosticEvent.Init.DiagnosticConfiguration(ldConfig); - JsonObject diagnosticJson = gson.toJsonTree(diagnosticConfiguration).getAsJsonObject(); - JsonObject expected = new JsonObject(); - expected.addProperty("allAttributesPrivate", true); - expected.addProperty("connectTimeoutMillis", 5_000); - expected.addProperty("customBaseURI", true); - expected.addProperty("customEventsURI", true); - expected.addProperty("customStreamURI", true); - expected.addProperty("diagnosticRecordingIntervalMillis", 1_800_000); - expected.addProperty("eventsCapacity", 20_000); - expected.addProperty("eventsFlushIntervalMillis",10_000); - expected.addProperty("featureStore", "RedisFeatureStoreBuilder"); - expected.addProperty("inlineUsersInEvents", true); - expected.addProperty("offline", true); - expected.addProperty("pollingIntervalMillis", 60_000); - expected.addProperty("reconnectTimeMillis", 2_000); - expected.addProperty("samplingInterval", 1); - expected.addProperty("socketTimeoutMillis", 20_000); - expected.addProperty("startWaitMillis", 10_000); - expected.addProperty("streamingDisabled", true); - expected.addProperty("userKeysCapacity", 2_000); - expected.addProperty("userKeysFlushIntervalMillis", 600_000); - expected.addProperty("usingProxy", true); - expected.addProperty("usingProxyAuthenticator", true); - expected.addProperty("usingRelayDaemon", true); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue expected = LDValue.buildObject() + .put("allAttributesPrivate", true) + .put("connectTimeoutMillis", 5_000) + .put("customBaseURI", true) + .put("customEventsURI", true) + .put("customStreamURI", true) + .put("dataStore", "Redis") + .put("diagnosticRecordingIntervalMillis", 1_800_000) + .put("eventsCapacity", 20_000) + .put("eventsFlushIntervalMillis",10_000) + .put("inlineUsersInEvents", true) + .put("offline", true) + .put("pollingIntervalMillis", 60_000) + .put("reconnectTimeMillis", 2_000) + .put("samplingInterval", 1) + .put("socketTimeoutMillis", 20_000) + .put("startWaitMillis", 10_000) + .put("streamingDisabled", true) + .put("userKeysCapacity", 2_000) + .put("userKeysFlushIntervalMillis", 600_000) + .put("usingProxy", true) + .put("usingProxyAuthenticator", true) + .put("usingRelayDaemon", true) + .build(); assertEquals(expected, diagnosticJson); } + @Test + public void customComponentCannotInjectOverlyLongData() { + LDConfig ldConfig = new LDConfig.Builder().dataStore(new FakeStoreFactory()).build(); + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + assertEquals(FakeStoreFactory.veryLongString().substring(0, 100), diagnosticJson.get("dataStore").stringValue()); + } + + private static class FakeStoreFactory implements FeatureStoreFactory, DiagnosticDescription { + @Override + public LDValue describeConfiguration() { + return LDValue.of(veryLongString()); + } + + @Override + public FeatureStore createFeatureStore() { + return null; + } + + public static String veryLongString() { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < 128; i++) { + b.append('@' + i); + } + return b.toString(); + } + } } From 0d9053915f6d40de7c2a9ea79bd66c4314704280 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Jan 2020 15:02:40 -0800 Subject: [PATCH 265/327] consistent verbiage --- src/main/java/com/launchdarkly/client/Components.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 9dc560fc1..a700a0810 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -127,7 +127,7 @@ public static EventProcessorFactory nullEventProcessor() { } /** - * Returns a configuration object for using streaming mode to get feature flag data. + * Returns a configurable factory for using streaming mode to get feature flag data. *

    * By default, the SDK uses a streaming connection to receive feature flag data from LaunchDarkly. To use the * default behavior, you do not need to call this method. However, if you want to customize the behavior of From a8c4c0223d1171f523981fe123342ee925d3894f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 23 Jan 2020 15:42:34 -0800 Subject: [PATCH 266/327] Copyedit --- .../client/integrations/PollingDataSourceBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java index 1628e6b4a..7999b63c1 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java @@ -8,7 +8,7 @@ /** * Contains methods for configuring the polling data source. *

    - * This is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag + * Polling is not the default behavior; by default, the SDK uses a streaming connection to receive feature flag * data from LaunchDarkly. In polling mode, the SDK instead makes a new HTTP request to LaunchDarkly at regular * intervals. HTTP caching allows it to avoid redundantly downloading data if there have been no changes, but * polling is still less efficient than streaming and should only be used on the advice of LaunchDarkly support. From 5daf1f8ef80d3388fcf2c629844ca97663d33fc8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Jan 2020 15:12:44 -0800 Subject: [PATCH 267/327] update diagnostic events for new event configuration system --- .../com/launchdarkly/client/Components.java | 47 ++++++++++--- .../client/DefaultEventProcessor.java | 26 +++---- .../client/DefaultFeatureRequestor.java | 4 +- .../launchdarkly/client/DiagnosticEvent.java | 24 ++++--- .../client/EventsConfiguration.java | 4 +- .../client/HttpConfiguration.java | 7 +- .../com/launchdarkly/client/LDConfig.java | 54 ++++----------- .../launchdarkly/client/StreamProcessor.java | 3 +- .../java/com/launchdarkly/client/Util.java | 2 +- .../integrations/EventProcessorBuilder.java | 31 ++++++++- .../client/DefaultEventProcessorTest.java | 3 +- .../client/DiagnosticEventTest.java | 67 ++++++++++++------- .../client/FeatureRequestorTest.java | 4 +- .../com/launchdarkly/client/LDConfigTest.java | 26 ++----- .../client/StreamProcessorTest.java | 4 +- .../com/launchdarkly/client/TestUtil.java | 3 +- .../EventProcessorBuilderTest.java | 29 ++++++++ 17 files changed, 201 insertions(+), 137 deletions(-) create mode 100644 src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 02bf95028..d461f3c6d 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -318,7 +318,7 @@ public LDValue describeConfiguration(LDConfig config) { } } - private static final class DefaultEventProcessorFactory implements EventProcessorFactoryWithDiagnostics { + private static final class DefaultEventProcessorFactory implements EventProcessorFactoryWithDiagnostics, DiagnosticDescription { @Override public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { return createEventProcessor(sdkKey, config, null); @@ -341,6 +341,23 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, return new NullEventProcessor(); } } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.buildObject() + .put(ConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, config.deprecatedAllAttributesPrivate) + .put(ConfigProperty.CUSTOM_EVENTS_URI.name, config.deprecatedEventsURI != null && + !config.deprecatedEventsURI.equals(LDConfig.DEFAULT_EVENTS_URI)) + .put(ConfigProperty.DIAGNOSTIC_RECORDING_INTERVAL_MILLIS.name, + EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS * 1000) // not configurable via deprecated API + .put(ConfigProperty.EVENTS_CAPACITY.name, config.deprecatedCapacity) + .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, config.deprecatedFlushInterval * 1000) + .put(ConfigProperty.INLINE_USERS_IN_EVENTS.name, config.deprecatedInlineUsersInEvents) + .put(ConfigProperty.SAMPLING_INTERVAL.name, config.deprecatedSamplingInterval) + .put(ConfigProperty.USER_KEYS_CAPACITY.name, config.deprecatedUserKeysCapacity) + .put(ConfigProperty.USER_KEYS_FLUSH_INTERVAL_MILLIS.name, config.deprecatedUserKeysFlushInterval * 1000) + .build(); + } } private static final class NullEventProcessorFactory implements EventProcessorFactory { @@ -381,12 +398,11 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea // by StreamingDataSourceBuilder and PollingDataSourceBuilder, and setting the latter is translated // into using externalUpdatesOnly() by LDConfig.Builder. if (config.deprecatedStream) { - StreamingDataSourceBuilder builder = streamingDataSource() + UpdateProcessorFactory upf = streamingDataSource() .baseUri(config.deprecatedStreamURI) .pollingBaseUri(config.deprecatedBaseURI) .initialReconnectDelayMillis(config.deprecatedReconnectTimeMs); - return ((UpdateProcessorFactoryWithDiagnostics)builder).createUpdateProcessor(sdkKey, config, - featureStore, diagnosticAccumulator); + return ((UpdateProcessorFactoryWithDiagnostics)upf).createUpdateProcessor(sdkKey, config, featureStore, diagnosticAccumulator); } else { return pollingDataSource() .baseUri(config.deprecatedBaseURI) @@ -497,7 +513,6 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( sdkKey, - config, config.httpConfig, pollUri, false @@ -505,7 +520,6 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea return new StreamProcessor( sdkKey, - config, config.httpConfig, requestor, featureStore, @@ -548,7 +562,6 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( sdkKey, - config, config.httpConfig, baseUri == null ? LDConfig.DEFAULT_BASE_URI : baseUri, true @@ -573,7 +586,7 @@ public LDValue describeConfiguration(LDConfig config) { } private static final class EventProcessorBuilderImpl extends EventProcessorBuilder - implements EventProcessorFactoryWithDiagnostics { + implements DiagnosticDescription, EventProcessorFactoryWithDiagnostics { @Override public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { return createEventProcessor(sdkKey, config, null); @@ -595,11 +608,27 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, Diagn privateAttrNames, 0, // deprecated samplingInterval isn't supported in new builder userKeysCapacity, - userKeysFlushIntervalSeconds + userKeysFlushIntervalSeconds, + diagnosticRecordingIntervalSeconds ), config.httpConfig, diagnosticAccumulator ); } + + @Override + public LDValue describeConfiguration(LDConfig config) { + return LDValue.buildObject() + .put(ConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, allAttributesPrivate) + .put(ConfigProperty.CUSTOM_EVENTS_URI.name, baseUri != null && !baseUri.equals(LDConfig.DEFAULT_EVENTS_URI)) + .put(ConfigProperty.DIAGNOSTIC_RECORDING_INTERVAL_MILLIS.name, diagnosticRecordingIntervalSeconds * 1000) + .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) + .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushIntervalSeconds * 1000) + .put(ConfigProperty.INLINE_USERS_IN_EVENTS.name, inlineUsersInEvents) + .put(ConfigProperty.SAMPLING_INTERVAL.name, 0) + .put(ConfigProperty.USER_KEYS_CAPACITY.name, userKeysCapacity) + .put(ConfigProperty.USER_KEYS_FLUSH_INTERVAL_MILLIS.name, userKeysFlushIntervalSeconds * 1000) + .build(); + } } } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 3db66a5d7..9563ad206 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -78,13 +78,14 @@ public void run() { }; this.scheduler.scheduleAtFixedRate(userKeysFlusher, eventsConfig.userKeysFlushIntervalSeconds, eventsConfig.userKeysFlushIntervalSeconds, TimeUnit.SECONDS); - if (!config.diagnosticOptOut && diagnosticAccumulator != null) { + if (diagnosticAccumulator != null) { // note that we don't pass a diagnosticAccumulator if diagnosticOptOut was true Runnable diagnosticsTrigger = new Runnable() { public void run() { postMessageAsync(MessageType.DIAGNOSTIC, null); } }; - this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, config.diagnosticRecordingIntervalMillis, config.diagnosticRecordingIntervalMillis, TimeUnit.MILLISECONDS); + this.scheduler.scheduleAtFixedRate(diagnosticsTrigger, eventsConfig.diagnosticRecordingIntervalSeconds, + eventsConfig.diagnosticRecordingIntervalSeconds, TimeUnit.SECONDS); } } @@ -233,7 +234,6 @@ private EventDispatcher(String sdkKey, LDConfig config, EventsConfiguration even // picked up by any worker, so if we try to push another one and are refused, it means // all the workers are busy. final BlockingQueue payloadQueue = new ArrayBlockingQueue<>(1); - final EventBuffer outbox = new EventBuffer(eventsConfig.capacity); final SimpleLRUCache userKeys = new SimpleLRUCache(eventsConfig.userKeysCapacity); @@ -271,14 +271,14 @@ public void handleResponse(Response response, Date responseDate) { } }; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { - SendEventsTask task = new SendEventsTask(sdkKey, config, eventsConfig, httpClient, listener, payloadQueue, + SendEventsTask task = new SendEventsTask(sdkKey, eventsConfig, httpClient, httpConfig, listener, payloadQueue, busyFlushWorkersCount, threadFactory); flushWorkers.add(task); } - if (!config.diagnosticOptOut && diagnosticAccumulator != null) { + if (diagnosticAccumulator != null) { // Set up diagnostics - this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(sdkKey, config, eventsConfig, httpClient); + this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(sdkKey, eventsConfig, httpClient, httpConfig); diagnosticExecutor = Executors.newSingleThreadExecutor(threadFactory); DiagnosticEvent.Init diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, config); diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); @@ -606,14 +606,14 @@ private static final class SendEventsTask implements Runnable { private final AtomicBoolean stopping; private final EventOutputFormatter formatter; private final Thread thread; - private final String uriStr; private final Headers headers; + private final String uriStr; private final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); // need one instance per task because the date parser isn't thread-safe - SendEventsTask(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, OkHttpClient httpClient, - EventResponseListener responseListener, BlockingQueue payloadQueue, AtomicInteger activeFlushWorkersCount, - ThreadFactory threadFactory) { + SendEventsTask(String sdkKey, EventsConfiguration eventsConfig, OkHttpClient httpClient, HttpConfiguration httpConfig, + EventResponseListener responseListener, BlockingQueue payloadQueue, + AtomicInteger activeFlushWorkersCount, ThreadFactory threadFactory) { this.httpClient = httpClient; this.formatter = new EventOutputFormatter(eventsConfig); this.responseListener = responseListener; @@ -621,7 +621,7 @@ private static final class SendEventsTask implements Runnable { this.activeFlushWorkersCount = activeFlushWorkersCount; this.stopping = new AtomicBoolean(false); this.uriStr = eventsConfig.eventsUri.toString() + "/bulk"; - this.headers = getHeadersBuilderFor(sdkKey, config) + this.headers = getHeadersBuilderFor(sdkKey, httpConfig) .add("Content-Type", "application/json") .add(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION) .build(); @@ -672,10 +672,10 @@ private static final class SendDiagnosticTaskFactory { private final String uriStr; private final Headers headers; - SendDiagnosticTaskFactory(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, OkHttpClient httpClient) { + SendDiagnosticTaskFactory(String sdkKey, EventsConfiguration eventsConfig, OkHttpClient httpClient, HttpConfiguration httpConfig) { this.httpClient = httpClient; this.uriStr = eventsConfig.eventsUri.toString() + "/diagnostic"; - this.headers = getHeadersBuilderFor(sdkKey, config) + this.headers = getHeadersBuilderFor(sdkKey, httpConfig) .add("Content-Type", "application/json") .build(); } diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index aa8b7807f..24c0e00e4 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -39,12 +39,13 @@ final class DefaultFeatureRequestor implements FeatureRequestor { private final Headers headers; private final boolean useCache; - DefaultFeatureRequestor(String sdkKey, LDConfig config, HttpConfiguration httpConfig, URI baseUri, boolean useCache) { + DefaultFeatureRequestor(String sdkKey, HttpConfiguration httpConfig, URI baseUri, boolean useCache) { this.baseUri = baseUri; this.useCache = useCache; OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfig, httpBuilder); + this.headers = getHeadersBuilderFor(sdkKey, httpConfig).build(); // HTTP caching is used only for FeatureRequestor. However, when streaming is enabled, HTTP GETs // made by FeatureRequester will always guarantee a new flag state, so we disable the cache. @@ -55,7 +56,6 @@ final class DefaultFeatureRequestor implements FeatureRequestor { } httpClient = httpBuilder.build(); - headers = getHeadersBuilderFor(sdkKey, config).build(); } public void close() { diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 8a6e2709b..62f599a21 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -9,12 +9,20 @@ class DiagnosticEvent { static enum ConfigProperty { + ALL_ATTRIBUTES_PRIVATE("allAttributesPrivate", LDValueType.BOOLEAN), CUSTOM_BASE_URI("customBaseURI", LDValueType.BOOLEAN), CUSTOM_EVENTS_URI("customEventsURI", LDValueType.BOOLEAN), CUSTOM_STREAM_URI("customStreamURI", LDValueType.BOOLEAN), + DIAGNOSTIC_RECORDING_INTERVAL_MILLIS("diagnosticRecordingIntervalMillis", LDValueType.NUMBER), + EVENTS_CAPACITY("eventsCapacity", LDValueType.NUMBER), + EVENTS_FLUSH_INTERVAL_MILLIS("eventsFlushIntervalMillis", LDValueType.NUMBER), + INLINE_USERS_IN_EVENTS("inlineUsersInEvents", LDValueType.BOOLEAN), POLLING_INTERVAL_MILLIS("pollingIntervalMillis", LDValueType.NUMBER), RECONNECT_TIME_MILLIS("reconnectTimeMillis", LDValueType.NUMBER), + SAMPLING_INTERVAL("samplingInterval", LDValueType.NUMBER), STREAMING_DISABLED("streamingDisabled", LDValueType.BOOLEAN), + USER_KEYS_CAPACITY("userKeysCapacity", LDValueType.NUMBER), + USER_KEYS_FLUSH_INTERVAL_MILLIS("userKeysFlushIntervalMillis", LDValueType.NUMBER), USING_RELAY_DAEMON("usingRelayDaemon", LDValueType.BOOLEAN); String name; @@ -83,21 +91,12 @@ static LDValue getConfigurationData(LDConfig config) { ObjectBuilder builder = LDValue.buildObject(); // Add the top-level properties that are not specific to a particular component type. - builder.put("customEventsURI", !(LDConfig.DEFAULT_EVENTS_URI.equals(config.deprecatedEventsURI))); - builder.put("eventsCapacity", config.deprecatedCapacity); builder.put("connectTimeoutMillis", config.httpConfig.connectTimeoutUnit.toMillis(config.httpConfig.connectTimeout)); builder.put("socketTimeoutMillis", config.httpConfig.socketTimeoutUnit.toMillis(config.httpConfig.socketTimeout)); - builder.put("eventsFlushIntervalMillis", config.deprecatedFlushInterval * 1000); builder.put("usingProxy", config.httpConfig.proxy != null); builder.put("usingProxyAuthenticator", config.httpConfig.proxyAuthenticator != null); builder.put("offline", config.offline); - builder.put("allAttributesPrivate", config.deprecatedAllAttributesPrivate); builder.put("startWaitMillis", config.startWaitMillis); - builder.put("samplingInterval", config.deprecatedSamplingInterval); - builder.put("userKeysCapacity", config.deprecatedUserKeysCapacity); - builder.put("userKeysFlushIntervalMillis", config.deprecatedUserKeysFlushInterval * 1000); - builder.put("inlineUsersInEvents", config.deprecatedInlineUsersInEvents); - builder.put("diagnosticRecordingIntervalMillis", config.diagnosticRecordingIntervalMillis); // Allow each pluggable component to describe its own relevant properties. mergeComponentProperties(builder, config.deprecatedFeatureStore, config, "dataStoreType"); @@ -107,6 +106,9 @@ static LDValue getConfigurationData(LDConfig config) { mergeComponentProperties(builder, config.dataSourceFactory == null ? Components.defaultUpdateProcessor() : config.dataSourceFactory, config, null); + mergeComponentProperties(builder, + config.eventProcessorFactory == null ? Components.defaultEventProcessor() : config.eventProcessorFactory, + config, null); return builder.build(); } @@ -153,8 +155,8 @@ static class DiagnosticSdk { final String wrapperVersion; DiagnosticSdk(LDConfig config) { - this.wrapperName = config.wrapperName; - this.wrapperVersion = config.wrapperVersion; + this.wrapperName = config.httpConfig.wrapperName; + this.wrapperVersion = config.httpConfig.wrapperVersion; } } diff --git a/src/main/java/com/launchdarkly/client/EventsConfiguration.java b/src/main/java/com/launchdarkly/client/EventsConfiguration.java index b487b745d..12292f282 100644 --- a/src/main/java/com/launchdarkly/client/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/client/EventsConfiguration.java @@ -16,10 +16,11 @@ final class EventsConfiguration { final int samplingInterval; final int userKeysCapacity; final int userKeysFlushIntervalSeconds; + final int diagnosticRecordingIntervalSeconds; EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, int flushIntervalSeconds, boolean inlineUsersInEvents, Set privateAttrNames, int samplingInterval, - int userKeysCapacity, int userKeysFlushIntervalSeconds) { + int userKeysCapacity, int userKeysFlushIntervalSeconds, int diagnosticRecordingIntervalSeconds) { super(); this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity; @@ -30,5 +31,6 @@ final class EventsConfiguration { this.samplingInterval = samplingInterval; this.userKeysCapacity = userKeysCapacity; this.userKeysFlushIntervalSeconds = userKeysFlushIntervalSeconds; + this.diagnosticRecordingIntervalSeconds = diagnosticRecordingIntervalSeconds; } } \ No newline at end of file diff --git a/src/main/java/com/launchdarkly/client/HttpConfiguration.java b/src/main/java/com/launchdarkly/client/HttpConfiguration.java index 0f551c594..7ca4593c6 100644 --- a/src/main/java/com/launchdarkly/client/HttpConfiguration.java +++ b/src/main/java/com/launchdarkly/client/HttpConfiguration.java @@ -18,9 +18,12 @@ final class HttpConfiguration { final TimeUnit socketTimeoutUnit; final SSLSocketFactory sslSocketFactory; final X509TrustManager trustManager; + final String wrapperName; + final String wrapperVersion; HttpConfiguration(int connectTimeout, TimeUnit connectTimeoutUnit, Proxy proxy, Authenticator proxyAuthenticator, - int socketTimeout, TimeUnit socketTimeoutUnit, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { + int socketTimeout, TimeUnit socketTimeoutUnit, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, + String wrapperName, String wrapperVersion) { super(); this.connectTimeout = connectTimeout; this.connectTimeoutUnit = connectTimeoutUnit; @@ -30,5 +33,7 @@ final class HttpConfiguration { this.socketTimeoutUnit = socketTimeoutUnit; this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; + this.wrapperName = wrapperName; + this.wrapperVersion = wrapperVersion; } } diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index f326e1f11..a2443feb8 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -42,14 +42,13 @@ public final class LDConfig { private static final int DEFAULT_USER_KEYS_CAPACITY = 1000; private static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; private static final long DEFAULT_RECONNECT_TIME_MILLIS = StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; - private static final int DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS = 900_000; // 15 minutes - private static final int MIN_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS = 60_000; // 1 minute protected static final LDConfig DEFAULT = new Builder().build(); final FeatureStoreFactory dataStoreFactory; final EventProcessorFactory eventProcessorFactory; final UpdateProcessorFactory dataSourceFactory; + final boolean diagnosticOptOut; final boolean offline; final long startWaitMillis; final HttpConfiguration httpConfig; @@ -70,16 +69,12 @@ public final class LDConfig { final int deprecatedUserKeysCapacity; final int deprecatedUserKeysFlushInterval; final boolean deprecatedInlineUsersInEvents; - - final int diagnosticRecordingIntervalMillis; - final boolean diagnosticOptOut; - final String wrapperName; - final String wrapperVersion; - + protected LDConfig(Builder builder) { this.dataStoreFactory = builder.dataStoreFactory; this.eventProcessorFactory = builder.eventProcessorFactory; this.dataSourceFactory = builder.dataSourceFactory; + this.diagnosticOptOut = builder.diagnosticOptOut; this.offline = builder.offline; this.startWaitMillis = builder.startWaitMillis; @@ -104,15 +99,6 @@ protected LDConfig(Builder builder) { this.deprecatedUserKeysFlushInterval = builder.userKeysFlushInterval; this.deprecatedInlineUsersInEvents = builder.inlineUsersInEvents; - if (builder.diagnosticRecordingIntervalMillis < MIN_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS) { - this.diagnosticRecordingIntervalMillis = MIN_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS; - } else { - this.diagnosticRecordingIntervalMillis = builder.diagnosticRecordingIntervalMillis; - } - this.diagnosticOptOut = builder.diagnosticOptOut; - this.wrapperName = builder.wrapperName; - this.wrapperVersion = builder.wrapperVersion; - Proxy proxy = builder.proxy(); Authenticator proxyAuthenticator = builder.proxyAuthenticator(); if (proxy != null) { @@ -125,20 +111,17 @@ protected LDConfig(Builder builder) { this.httpConfig = new HttpConfiguration(builder.connectTimeout, builder.connectTimeoutUnit, proxy, proxyAuthenticator, builder.socketTimeout, builder.socketTimeoutUnit, - builder.sslSocketFactory, builder.trustManager); + builder.sslSocketFactory, builder.trustManager, builder.wrapperName, builder.wrapperVersion); } LDConfig(LDConfig config) { this.dataSourceFactory = config.dataSourceFactory; this.dataStoreFactory = config.dataStoreFactory; this.diagnosticOptOut = config.diagnosticOptOut; - this.diagnosticRecordingIntervalMillis = config.diagnosticRecordingIntervalMillis; this.eventProcessorFactory = config.eventProcessorFactory; this.httpConfig = config.httpConfig; this.offline = config.offline; this.startWaitMillis = config.startWaitMillis; - this.wrapperName = config.wrapperName; - this.wrapperVersion = config.wrapperVersion; this.deprecatedAllAttributesPrivate = config.deprecatedAllAttributesPrivate; this.deprecatedBaseURI = config.deprecatedBaseURI; @@ -147,13 +130,13 @@ protected LDConfig(Builder builder) { this.deprecatedFeatureStore = config.deprecatedFeatureStore; this.deprecatedFlushInterval = config.deprecatedFlushInterval; this.deprecatedInlineUsersInEvents = config.deprecatedInlineUsersInEvents; - this.deprecatedReconnectTimeMs = config.deprecatedReconnectTimeMs; - this.deprecatedStream = config.deprecatedStream; - this.deprecatedStreamURI = config.deprecatedStreamURI; this.deprecatedPollingIntervalMillis = config.deprecatedPollingIntervalMillis; this.deprecatedPrivateAttrNames = config.deprecatedPrivateAttrNames; + this.deprecatedReconnectTimeMs = config.deprecatedReconnectTimeMs; this.deprecatedSamplingInterval = config.deprecatedSamplingInterval; this.deprecatedSendEvents = config.deprecatedSendEvents; + this.deprecatedStream = config.deprecatedStream; + this.deprecatedStreamURI = config.deprecatedStreamURI; this.deprecatedUserKeysCapacity = config.deprecatedUserKeysCapacity; this.deprecatedUserKeysFlushInterval = config.deprecatedUserKeysFlushInterval; } @@ -177,6 +160,7 @@ public static class Builder { private TimeUnit connectTimeoutUnit = TimeUnit.MILLISECONDS; private int socketTimeout = DEFAULT_SOCKET_TIMEOUT_MILLIS; private TimeUnit socketTimeoutUnit = TimeUnit.MILLISECONDS; + private boolean diagnosticOptOut = false; private int capacity = DEFAULT_CAPACITY; private int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; private String proxyHost = "localhost"; @@ -201,8 +185,6 @@ public static class Builder { private boolean inlineUsersInEvents = false; private SSLSocketFactory sslSocketFactory = null; private X509TrustManager trustManager = null; - private int diagnosticRecordingIntervalMillis = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_MILLIS; - private boolean diagnosticOptOut = false; private String wrapperName = null; private String wrapperVersion = null; @@ -706,29 +688,17 @@ public Builder inlineUsersInEvents(boolean inlineUsersInEvents) { return this; } - /** - * Sets the interval at which periodic diagnostic data is sent. The default is every 15 minutes (900,000 - * milliseconds) and the minimum value is 60,000. - * - * @see #diagnosticOptOut(boolean) - * - * @param diagnosticRecordingIntervalMillis the diagnostics interval in milliseconds - * @return the builder - */ - public Builder diagnosticRecordingIntervalMillis(int diagnosticRecordingIntervalMillis) { - this.diagnosticRecordingIntervalMillis = diagnosticRecordingIntervalMillis; - return this; - } - /** * Set to true to opt out of sending diagnostics data. - * - * Unless the diagnosticOptOut field is set to true, the client will send some diagnostics data to the + *

    + * Unless {@code diagnosticOptOut} is set to true, the client will send some diagnostics data to the * LaunchDarkly servers in order to assist in the development of future SDK improvements. These diagnostics * consist of an initial payload containing some details of SDK in use, the SDK's configuration, and the platform * the SDK is being run on; as well as payloads sent periodically with information on irregular occurrences such * as dropped events. * + * @see com.launchdarkly.client.integrations.EventProcessorBuilder#diagnosticRecordingIntervalSeconds(int) + * * @param diagnosticOptOut true if you want to opt out of sending any diagnostics data * @return the builder */ diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 02ed17493..3e43706fb 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -57,7 +57,6 @@ EventSource createEventSource(EventHandler handler, URI streamUri, long initialR StreamProcessor( String sdkKey, - LDConfig config, HttpConfiguration httpConfig, FeatureRequestor requestor, FeatureStore featureStore, @@ -74,7 +73,7 @@ EventSource createEventSource(EventHandler handler, URI streamUri, long initialR this.streamUri = streamUri; this.initialReconnectDelayMillis = initialReconnectDelayMillis; - this.headers = getHeadersBuilderFor(sdkKey, config) + this.headers = getHeadersBuilderFor(sdkKey, httpConfig) .add("Accept", "text/event-stream") .build(); } diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index a157aecf6..4fb5fe112 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -33,7 +33,7 @@ static DateTime jsonPrimitiveToDateTime(LDValue maybeDate) { } } - static Headers.Builder getHeadersBuilderFor(String sdkKey, LDConfig config) { + static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration config) { Headers.Builder builder = new Headers.Builder() .add("Authorization", sdkKey) .add("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION); diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java index 99dca7b78..8a065846e 100644 --- a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java @@ -32,6 +32,11 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { * The default value for {@link #capacity(int)}. */ public static final int DEFAULT_CAPACITY = 10000; + + /** + * The default value for {@link #diagnosticRecordingIntervalSeconds(int)}. + */ + public static final int DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS = 60 * 15; /** * The default value for {@link #flushIntervalSeconds(int)}. @@ -48,9 +53,15 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { */ public static final int DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS = 60 * 5; + /** + * The minimum value for {@link #diagnosticRecordingIntervalSeconds(int)}. + */ + public static final int MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS = 60; + protected boolean allAttributesPrivate = false; protected URI baseUri; protected int capacity = DEFAULT_CAPACITY; + protected int diagnosticRecordingIntervalSeconds = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS; protected int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; protected boolean inlineUsersInEvents = false; protected Set privateAttrNames; @@ -108,7 +119,25 @@ public EventProcessorBuilder capacity(int capacity) { this.capacity = capacity; return this; } - + + /** + * Sets the interval at which periodic diagnostic data is sent. + *

    + * The default value is {@link #DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS}; the minimum value is + * {@link #MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS}. This property is ignored if + * {@link com.launchdarkly.client.LDConfig.Builder#diagnosticOptOut(boolean)} is set to {@code true}. + * + * @see com.launchdarkly.client.LDConfig.Builder#diagnosticOptOut(boolean) + * + * @param diagnosticRecordingIntervalSeconds the diagnostics interval in seconds + * @return the builder + */ + public EventProcessorBuilder diagnosticRecordingIntervalSeconds(int diagnosticRecordingIntervalSeconds) { + this.diagnosticRecordingIntervalSeconds = diagnosticRecordingIntervalSeconds < MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS ? + MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS : diagnosticRecordingIntervalSeconds; + return this; + } + /** * Sets the interval between flushes of the event buffer. *

    diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 55152ab1d..4dfb5f4d6 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -310,8 +310,7 @@ public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); try (MockWebServer server = makeStartedServer(resp1, resp2)) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { // Send and flush an event we don't care about, just to set the last server time ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); ep.flush(); diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index 8041b775f..d5e549726 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -49,6 +49,11 @@ public void testSerialization() { } private ObjectBuilder expectedDefaultProperties() { + return expectedDefaultPropertiesWithoutStreaming() + .put("reconnectTimeMillis", 1_000); + } + + private ObjectBuilder expectedDefaultPropertiesWithoutStreaming() { return LDValue.buildObject() .put("allAttributesPrivate", false) .put("connectTimeoutMillis", 2_000) @@ -76,9 +81,7 @@ private ObjectBuilder expectedDefaultProperties() { public void testDefaultDiagnosticConfiguration() { LDConfig ldConfig = new LDConfig.Builder().build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() - .put("reconnectTimeMillis", 1_000) - .build(); + LDValue expected = expectedDefaultProperties().build(); assertEquals(expected, diagnosticJson); } @@ -86,16 +89,9 @@ public void testDefaultDiagnosticConfiguration() { @Test public void testCustomDiagnosticConfigurationGeneralProperties() { LDConfig ldConfig = new LDConfig.Builder() - .allAttributesPrivate(true) .connectTimeout(5) - .diagnosticRecordingIntervalMillis(1_800_000) - .capacity(20_000) - .flushInterval(10) - .inlineUsersInEvents(true) .socketTimeout(20) .startWaitMillis(10_000) - .userKeysCapacity(2_000) - .userKeysFlushInterval(600) .proxyPort(1234) .proxyUsername("username") .proxyPassword("password") @@ -103,17 +99,9 @@ public void testCustomDiagnosticConfigurationGeneralProperties() { LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultProperties() - .put("allAttributesPrivate", true) .put("connectTimeoutMillis", 5_000) - .put("diagnosticRecordingIntervalMillis", 1_800_000) - .put("eventsCapacity", 20_000) - .put("eventsFlushIntervalMillis", 10_000) - .put("inlineUsersInEvents", true) - .put("reconnectTimeMillis", 1_000) .put("socketTimeoutMillis", 20_000) .put("startWaitMillis", 10_000) - .put("userKeysCapacity", 2_000) - .put("userKeysFlushIntervalMillis", 600_000) .put("usingProxy", true) .put("usingProxyAuthenticator", true) .build(); @@ -133,7 +121,7 @@ public void testCustomDiagnosticConfigurationForStreaming() { .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("customStreamURI", true) .put("reconnectTimeMillis", 2_000) @@ -153,7 +141,7 @@ public void testCustomDiagnosticConfigurationForPolling() { .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("pollingIntervalMillis", 60_000) .put("streamingDisabled", true) @@ -162,6 +150,35 @@ public void testCustomDiagnosticConfigurationForPolling() { assertEquals(expected, diagnosticJson); } + @Test + public void testCustomDiagnosticConfigurationForEvents() { + LDConfig ldConfig = new LDConfig.Builder() + .events( + Components.sendEvents() + .allAttributesPrivate(true) + .capacity(20_000) + .diagnosticRecordingIntervalSeconds(1_800) + .flushIntervalSeconds(10) + .inlineUsersInEvents(true) + .userKeysCapacity(2_000) + .userKeysFlushIntervalSeconds(600) + ) + .build(); + + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue expected = expectedDefaultProperties() + .put("allAttributesPrivate", true) + .put("diagnosticRecordingIntervalMillis", 1_800_000) + .put("eventsCapacity", 20_000) + .put("eventsFlushIntervalMillis", 10_000) + .put("inlineUsersInEvents", true) + .put("userKeysCapacity", 2_000) + .put("userKeysFlushIntervalMillis", 600_000) + .build(); + + assertEquals(expected, diagnosticJson); + } + @Test public void testCustomDiagnosticConfigurationForDaemonMode() { LDConfig ldConfig = new LDConfig.Builder() @@ -170,7 +187,7 @@ public void testCustomDiagnosticConfigurationForDaemonMode() { .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("dataStoreType", "Redis") .put("usingRelayDaemon", true) .build(); @@ -185,7 +202,7 @@ public void testCustomDiagnosticConfigurationForOffline() { .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("offline", true) .build(); @@ -202,7 +219,7 @@ public void testCustomDiagnosticConfigurationDeprecatedPropertiesForStreaming() .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("customStreamURI", true) .put("reconnectTimeMillis", 2_000) @@ -221,7 +238,7 @@ public void testCustomDiagnosticConfigurationDeprecatedPropertiesForPolling() { .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("customBaseURI", true) .put("pollingIntervalMillis", 60_000) .put("streamingDisabled", true) @@ -239,7 +256,7 @@ public void testCustomDiagnosticConfigurationDeprecatedPropertyForDaemonMode() { .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); - LDValue expected = expectedDefaultProperties() + LDValue expected = expectedDefaultPropertiesWithoutStreaming() .put("dataStoreType", "Redis") .put("usingRelayDaemon", true) .build(); diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index 6de8cff1d..b5517c18f 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -40,7 +40,7 @@ private DefaultFeatureRequestor makeRequestor(MockWebServer server) { private DefaultFeatureRequestor makeRequestor(MockWebServer server, LDConfig config) { URI uri = server.url("").uri(); - return new DefaultFeatureRequestor(sdkKey, config, config.httpConfig, uri, true); + return new DefaultFeatureRequestor(sdkKey, config.httpConfig, uri, true); } @Test @@ -198,7 +198,7 @@ public void httpClientCanUseProxyConfig() throws Exception { .proxyPort(serverUrl.port()) .build(); - try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config, config.httpConfig, fakeBaseUri, true)) { + try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config.httpConfig, fakeBaseUri, true)) { FeatureFlag flag = r.getFlag(flag1Key); verifyFlag(flag, flag1Key); diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index ef93e3a98..0f9d074f1 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -98,24 +98,6 @@ public void testSendEventsCanBeSetToFalse() { assertEquals(false, config.deprecatedSendEvents); } - @Test - public void testDefaultDiagnosticRecordingInterval() { - LDConfig config = new LDConfig.Builder().build(); - assertEquals(900_000, config.diagnosticRecordingIntervalMillis); - } - - @Test - public void testDiagnosticRecordingInterval() { - LDConfig config = new LDConfig.Builder().diagnosticRecordingIntervalMillis(120_000).build(); - assertEquals(120_000, config.diagnosticRecordingIntervalMillis); - } - - @Test - public void testMinimumDiagnosticRecordingIntervalEnforced() { - LDConfig config = new LDConfig.Builder().diagnosticRecordingIntervalMillis(10).build(); - assertEquals(60_000, config.diagnosticRecordingIntervalMillis); - } - @Test public void testDefaultDiagnosticOptOut() { LDConfig config = new LDConfig.Builder().build(); @@ -131,8 +113,8 @@ public void testDiagnosticOptOut() { @Test public void testWrapperNotConfigured() { LDConfig config = new LDConfig.Builder().build(); - assertNull(config.wrapperName); - assertNull(config.wrapperVersion); + assertNull(config.httpConfig.wrapperName); + assertNull(config.httpConfig.wrapperVersion); } @Test public void testWrapperConfigured() { @@ -140,7 +122,7 @@ public void testWrapperNotConfigured() { .wrapperName("Scala") .wrapperVersion("0.1.0") .build(); - assertEquals("Scala", config.wrapperName); - assertEquals("0.1.0", config.wrapperVersion); + assertEquals("Scala", config.httpConfig.wrapperName); + assertEquals("0.1.0", config.httpConfig.wrapperVersion); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 4b0ff3a44..77c5cccfa 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -499,12 +499,12 @@ private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri) { } private StreamProcessor createStreamProcessor(LDConfig config, URI streamUri, DiagnosticAccumulator diagnosticAccumulator) { - return new StreamProcessor(SDK_KEY, config, config.httpConfig, mockRequestor, featureStore, new StubEventSourceCreator(), diagnosticAccumulator, + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, new StubEventSourceCreator(), diagnosticAccumulator, streamUri, config.deprecatedReconnectTimeMs); } private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI streamUri) { - return new StreamProcessor(SDK_KEY, config, config.httpConfig, mockRequestor, featureStore, null, null, + return new StreamProcessor(SDK_KEY, config.httpConfig, mockRequestor, featureStore, null, null, streamUri, config.deprecatedReconnectTimeMs); } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 7f54030b4..5d2f5f729 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -5,6 +5,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.integrations.EventProcessorBuilder; import com.launchdarkly.client.value.LDValue; import org.hamcrest.Description; @@ -284,7 +285,7 @@ static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolea 0, null, 0, inlineUsersInEvents, privateAttrNames, - 0, 0, 0); + 0, 0, 0, EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS); } static EventsConfiguration defaultEventsConfig() { diff --git a/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java new file mode 100644 index 000000000..689c210fa --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/EventProcessorBuilderTest.java @@ -0,0 +1,29 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +@SuppressWarnings("javadoc") +public class EventProcessorBuilderTest { + @Test + public void testDefaultDiagnosticRecordingInterval() { + EventProcessorBuilder builder = Components.sendEvents(); + assertEquals(900, builder.diagnosticRecordingIntervalSeconds); + } + + @Test + public void testDiagnosticRecordingInterval() { + EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingIntervalSeconds(120); + assertEquals(120, builder.diagnosticRecordingIntervalSeconds); + } + + @Test + public void testMinimumDiagnosticRecordingIntervalEnforced() { + EventProcessorBuilder builder = Components.sendEvents().diagnosticRecordingIntervalSeconds(10); + assertEquals(60, builder.diagnosticRecordingIntervalSeconds); + } + +} From 38fc128efd8b0cf711894af9c2014584aaab19d2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Jan 2020 16:06:28 -0800 Subject: [PATCH 268/327] @see doc links --- src/main/java/com/launchdarkly/client/Components.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 4fcd6a54a..f5c35bdb6 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -128,6 +128,7 @@ public static RedisFeatureStoreBuilder redisFeatureStore(URI redisUri) { * * @return a builder for setting streaming connection properties * @see #noEvents() + * @see LDConfig.Builder#events * @since 4.12.0 */ public static EventProcessorBuilder sendEvents() { @@ -167,6 +168,7 @@ public static EventProcessorFactory defaultEventProcessor() { * * * @return a factory object + * @see #sendEvents() * @see LDConfig.Builder#events(EventProcessorFactory) * @since 4.12.0 */ @@ -205,6 +207,7 @@ public static EventProcessorFactory nullEventProcessor() { * will be renamed to {@code DataSourceFactory}.) * * @return a builder for setting streaming connection properties + * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) * @since 4.12.0 */ public static StreamingDataSourceBuilder streamingDataSource() { @@ -235,6 +238,7 @@ public static StreamingDataSourceBuilder streamingDataSource() { * will be renamed to {@code DataSourceFactory}.) * * @return a builder for setting polling properties + * @see LDConfig.Builder#dataSource(UpdateProcessorFactory) * @since 4.12.0 */ public static PollingDataSourceBuilder pollingDataSource() { From 8a0751d4ad0b8152d0640ca3b3535344c977c984 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 27 Jan 2020 16:06:33 -0800 Subject: [PATCH 269/327] rm unused --- .../java/com/launchdarkly/client/Util.java | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 62b243cd5..01eeaa1fd 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -51,25 +51,6 @@ static void configureHttpClientBuilder(HttpConfiguration config, OkHttpClient.Bu } } -// static void configureHttpClientBuilder(LDConfig config, OkHttpClient.Builder builder) { -// builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) -// .connectTimeout(config.connectTimeout, config.connectTimeoutUnit) -// .readTimeout(config.socketTimeout, config.socketTimeoutUnit) -// .writeTimeout(config.socketTimeout, config.socketTimeoutUnit) -// .retryOnConnectionFailure(false); // we will implement our own retry logic -// -// if (config.sslSocketFactory != null) { -// builder.sslSocketFactory(config.sslSocketFactory, config.trustManager); -// } -// -// if (config.proxy != null) { -// builder.proxy(config.proxy); -// if (config.proxyAuthenticator != null) { -// builder.proxyAuthenticator(config.proxyAuthenticator); -// } -// } -// } - static Request.Builder getRequestBuilder(String sdkKey) { return new Request.Builder() .addHeader("Authorization", sdkKey) From 67175d3ecffcee6629d0647dcd115deff2dd03f3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jan 2020 15:45:07 -0800 Subject: [PATCH 270/327] misc cleanup in new APIs --- .../com/launchdarkly/client/Components.java | 59 ++++++++++++++----- .../com/launchdarkly/client/LDConfig.java | 6 +- .../integrations/EventProcessorBuilder.java | 12 ++-- .../PersistentDataStoreBuilder.java | 37 +++--------- .../PollingDataSourceBuilder.java | 8 +-- .../StreamingDataSourceBuilder.java | 20 +++---- .../client/DefaultEventProcessorTest.java | 6 +- .../client/DiagnosticEventTest.java | 6 +- .../com/launchdarkly/client/LDClientTest.java | 4 +- .../com/launchdarkly/client/TestHttpUtil.java | 4 +- 10 files changed, 85 insertions(+), 77 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index a4ec9b05e..751e40692 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -7,6 +7,8 @@ import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; +import com.launchdarkly.client.utils.CachingStoreWrapper; +import com.launchdarkly.client.utils.FeatureStoreCore; import com.launchdarkly.client.value.LDValue; import java.io.IOException; @@ -28,6 +30,7 @@ * * @since 4.0.0 */ +@SuppressWarnings("deprecation") public abstract class Components { private static final FeatureStoreFactory inMemoryFeatureStoreFactory = new InMemoryFeatureStoreFactory(); private static final EventProcessorFactory defaultEventProcessorFactory = new DefaultEventProcessorFactory(); @@ -79,7 +82,7 @@ public static FeatureStoreFactory inMemoryDataStore() { * @since 4.12.0 */ public static PersistentDataStoreBuilder persistentDataStore(PersistentDataStoreFactory storeFactory) { - return new PersistentDataStoreBuilder(storeFactory); + return new PersistentDataStoreBuilderImpl(storeFactory); } /** @@ -322,6 +325,7 @@ public LDValue describeConfiguration(LDConfig config) { } } + // This can be removed once the deprecated event config options have been removed. private static final class DefaultEventProcessorFactory implements EventProcessorFactoryWithDiagnostics, DiagnosticDescription { @Override public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { @@ -333,7 +337,7 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, if (config.deprecatedSendEvents && !config.offline) { EventProcessorFactory epf = sendEvents() .allAttributesPrivate(config.deprecatedAllAttributesPrivate) - .baseUri(config.deprecatedEventsURI) + .baseURI(config.deprecatedEventsURI) .capacity(config.deprecatedCapacity) .flushIntervalSeconds(config.deprecatedFlushInterval) .inlineUsersInEvents(config.deprecatedInlineUsersInEvents) @@ -403,13 +407,13 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea // into using externalUpdatesOnly() by LDConfig.Builder. if (config.deprecatedStream) { StreamingDataSourceBuilderImpl builder = (StreamingDataSourceBuilderImpl)streamingDataSource() - .baseUri(config.deprecatedStreamURI) - .pollingBaseUri(config.deprecatedBaseURI) + .baseURI(config.deprecatedStreamURI) + .pollingBaseURI(config.deprecatedBaseURI) .initialReconnectDelayMillis(config.deprecatedReconnectTimeMs); return builder.createUpdateProcessor(sdkKey, config, featureStore, diagnosticAccumulator); } else { return pollingDataSource() - .baseUri(config.deprecatedBaseURI) + .baseURI(config.deprecatedBaseURI) .pollIntervalMillis(config.deprecatedPollingIntervalMillis) .createUpdateProcessor(sdkKey, config, featureStore); } @@ -505,14 +509,14 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea LDClient.logger.info("Enabling streaming API"); - URI streamUri = baseUri == null ? LDConfig.DEFAULT_STREAM_URI : baseUri; + URI streamUri = baseURI == null ? LDConfig.DEFAULT_STREAM_URI : baseURI; URI pollUri; - if (pollingBaseUri != null) { - pollUri = pollingBaseUri; + if (pollingBaseURI != null) { + pollUri = pollingBaseURI; } else { // If they have set a custom base URI, and they did *not* set a custom polling URI, then we can // assume they're using Relay in which case both of those values are the same. - pollUri = baseUri == null ? LDConfig.DEFAULT_BASE_URI : baseUri; + pollUri = baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI; } DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( @@ -542,10 +546,10 @@ public LDValue describeConfiguration(LDConfig config) { return LDValue.buildObject() .put(ConfigProperty.STREAMING_DISABLED.name, false) .put(ConfigProperty.CUSTOM_BASE_URI.name, - (pollingBaseUri != null && !pollingBaseUri.equals(LDConfig.DEFAULT_BASE_URI)) || - (pollingBaseUri == null && baseUri != null && !baseUri.equals(LDConfig.DEFAULT_STREAM_URI))) + (pollingBaseURI != null && !pollingBaseURI.equals(LDConfig.DEFAULT_BASE_URI)) || + (pollingBaseURI == null && baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI))) .put(ConfigProperty.CUSTOM_STREAM_URI.name, - baseUri != null && !baseUri.equals(LDConfig.DEFAULT_STREAM_URI)) + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_STREAM_URI)) .put(ConfigProperty.RECONNECT_TIME_MILLIS.name, initialReconnectDelayMillis) .put(ConfigProperty.USING_RELAY_DAEMON.name, false) .build(); @@ -567,7 +571,7 @@ public UpdateProcessor createUpdateProcessor(String sdkKey, LDConfig config, Fea DefaultFeatureRequestor requestor = new DefaultFeatureRequestor( sdkKey, config.httpConfig, - baseUri == null ? LDConfig.DEFAULT_BASE_URI : baseUri, + baseURI == null ? LDConfig.DEFAULT_BASE_URI : baseURI, true ); return new PollingProcessor(requestor, featureStore, pollIntervalMillis); @@ -581,7 +585,7 @@ public LDValue describeConfiguration(LDConfig config) { return LDValue.buildObject() .put(ConfigProperty.STREAMING_DISABLED.name, true) .put(ConfigProperty.CUSTOM_BASE_URI.name, - baseUri != null && !baseUri.equals(LDConfig.DEFAULT_BASE_URI)) + baseURI != null && !baseURI.equals(LDConfig.DEFAULT_BASE_URI)) .put(ConfigProperty.CUSTOM_STREAM_URI.name, false) .put(ConfigProperty.POLLING_INTERVAL_MILLIS.name, pollIntervalMillis) .put(ConfigProperty.USING_RELAY_DAEMON.name, false) @@ -606,7 +610,7 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, Diagn new EventsConfiguration( allAttributesPrivate, capacity, - baseUri == null ? LDConfig.DEFAULT_EVENTS_URI : baseUri, + baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, flushIntervalSeconds, inlineUsersInEvents, privateAttrNames, @@ -624,7 +628,7 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, Diagn public LDValue describeConfiguration(LDConfig config) { return LDValue.buildObject() .put(ConfigProperty.ALL_ATTRIBUTES_PRIVATE.name, allAttributesPrivate) - .put(ConfigProperty.CUSTOM_EVENTS_URI.name, baseUri != null && !baseUri.equals(LDConfig.DEFAULT_EVENTS_URI)) + .put(ConfigProperty.CUSTOM_EVENTS_URI.name, baseURI != null && !baseURI.equals(LDConfig.DEFAULT_EVENTS_URI)) .put(ConfigProperty.DIAGNOSTIC_RECORDING_INTERVAL_MILLIS.name, diagnosticRecordingIntervalSeconds * 1000) .put(ConfigProperty.EVENTS_CAPACITY.name, capacity) .put(ConfigProperty.EVENTS_FLUSH_INTERVAL_MILLIS.name, flushIntervalSeconds * 1000) @@ -635,4 +639,27 @@ public LDValue describeConfiguration(LDConfig config) { .build(); } } + + private static final class PersistentDataStoreBuilderImpl extends PersistentDataStoreBuilder implements DiagnosticDescription { + public PersistentDataStoreBuilderImpl(PersistentDataStoreFactory persistentDataStoreFactory) { + super(persistentDataStoreFactory); + } + + @Override + public FeatureStore createFeatureStore() { + FeatureStoreCore core = persistentDataStoreFactory.createPersistentDataStore(); + return CachingStoreWrapper.builder(core) + .caching(caching) + .cacheMonitor(cacheMonitor) + .build(); + } + + @Override + public LDValue describeConfiguration(LDConfig config) { + if (persistentDataStoreFactory instanceof DiagnosticDescription) { + return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); + } + return LDValue.of("?"); + } + } } diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 4d27cd522..2b331700c 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -202,8 +202,8 @@ public Builder() { * * @param baseURI the base URL of the LaunchDarkly server for this configuration. * @return the builder - * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseUri(URI)}, - * or {@link Components#pollingDataSource()} with {@link PollingDataSourceBuilder#baseUri(URI)}. + * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseURI(URI)}, + * or {@link Components#pollingDataSource()} with {@link PollingDataSourceBuilder#baseURI(URI)}. */ @Deprecated public Builder baseURI(URI baseURI) { @@ -230,7 +230,7 @@ public Builder eventsURI(URI eventsURI) { * * @param streamURI the base URL of the LaunchDarkly streaming server * @return the builder - * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseUri(URI)}. + * @deprecated Use {@link Components#streamingDataSource()} with {@link StreamingDataSourceBuilder#pollingBaseURI(URI)}. */ @Deprecated public Builder streamURI(URI streamURI) { diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java index 8a065846e..fb229fc61 100644 --- a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java @@ -59,7 +59,7 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { public static final int MIN_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS = 60; protected boolean allAttributesPrivate = false; - protected URI baseUri; + protected URI baseURI; protected int capacity = DEFAULT_CAPACITY; protected int diagnosticRecordingIntervalSeconds = DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS; protected int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; @@ -90,16 +90,16 @@ public EventProcessorBuilder allAttributesPrivate(boolean allAttributesPrivate) *

    * You will only need to change this value in the following cases: *

      - *
    • You are using the Relay Proxy. Set - * {@code streamUri} to the base URI of the Relay Proxy instance. + *
    • You are using the Relay Proxy with + * event forwarding enabled. Set {@code streamUri} to the base URI of the Relay Proxy instance. *
    • You are connecting to a test server or a nonstandard endpoint for the LaunchDarkly service. *
    * - * @param baseUri the base URI of the events service; null to use the default + * @param baseURI the base URI of the events service; null to use the default * @return the builder */ - public EventProcessorBuilder baseUri(URI baseUri) { - this.baseUri = baseUri; + public EventProcessorBuilder baseURI(URI baseURI) { + this.baseURI = baseURI; return this; } diff --git a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java index b10b92f08..21c0f1177 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PersistentDataStoreBuilder.java @@ -1,14 +1,10 @@ package com.launchdarkly.client.integrations; -import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.Components; import com.launchdarkly.client.FeatureStoreCacheConfig; import com.launchdarkly.client.FeatureStoreFactory; -import com.launchdarkly.client.LDConfig; import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; -import com.launchdarkly.client.utils.CachingStoreWrapper; -import com.launchdarkly.client.utils.FeatureStoreCore; -import com.launchdarkly.client.value.LDValue; import java.util.concurrent.TimeUnit; @@ -35,19 +31,21 @@ * * In this example, {@code .url()} is an option specifically for the Redis integration, whereas * {@code ttlSeconds()} is an option that can be used for any persistent data store. - * + *

    + * Note that this class is abstract; the actual implementation is created by calling + * {@link Components#persistentDataStore(PersistentDataStoreFactory)}. * @since 4.12.0 */ @SuppressWarnings("deprecation") -public final class PersistentDataStoreBuilder implements FeatureStoreFactory, DiagnosticDescription { +public abstract class PersistentDataStoreBuilder implements FeatureStoreFactory, DiagnosticDescription { /** * The default value for the cache TTL. */ public static final int DEFAULT_CACHE_TTL_SECONDS = 15; - private final PersistentDataStoreFactory persistentDataStoreFactory; - FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; - CacheMonitor cacheMonitor = null; + protected final PersistentDataStoreFactory persistentDataStoreFactory; + protected FeatureStoreCacheConfig caching = FeatureStoreCacheConfig.DEFAULT; + protected CacheMonitor cacheMonitor = null; /** * Possible values for {@link #staleValuesPolicy(StaleValuesPolicy)}. @@ -99,19 +97,10 @@ public enum StaleValuesPolicy { * * @param persistentDataStoreFactory the factory implementation for the specific data store type */ - public PersistentDataStoreBuilder(PersistentDataStoreFactory persistentDataStoreFactory) { + protected PersistentDataStoreBuilder(PersistentDataStoreFactory persistentDataStoreFactory) { this.persistentDataStoreFactory = persistentDataStoreFactory; } - @Override - public FeatureStore createFeatureStore() { - FeatureStoreCore core = persistentDataStoreFactory.createPersistentDataStore(); - return CachingStoreWrapper.builder(core) - .caching(caching) - .cacheMonitor(cacheMonitor) - .build(); - } - /** * Specifies that the SDK should not use an in-memory cache for the persistent data store. * This means that every feature flag evaluation will trigger a data store query. @@ -216,12 +205,4 @@ public PersistentDataStoreBuilder cacheMonitor(CacheMonitor cacheMonitor) { this.cacheMonitor = cacheMonitor; return this; } - - @Override - public LDValue describeConfiguration(LDConfig config) { - if (persistentDataStoreFactory instanceof DiagnosticDescription) { - return ((DiagnosticDescription)persistentDataStoreFactory).describeConfiguration(config); - } - return LDValue.of("?"); - } } diff --git a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java index 7999b63c1..72a461cbd 100644 --- a/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/PollingDataSourceBuilder.java @@ -34,7 +34,7 @@ public abstract class PollingDataSourceBuilder implements UpdateProcessorFactory */ public static final long DEFAULT_POLL_INTERVAL_MILLIS = 30000L; - protected URI baseUri; + protected URI baseURI; protected long pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS; /** @@ -47,11 +47,11 @@ public abstract class PollingDataSourceBuilder implements UpdateProcessorFactory *

  • You are connecting to a test server or anything else other than the standard LaunchDarkly service. * * - * @param baseUri the base URI of the polling service; null to use the default + * @param baseURI the base URI of the polling service; null to use the default * @return the builder */ - public PollingDataSourceBuilder baseUri(URI baseUri) { - this.baseUri = baseUri; + public PollingDataSourceBuilder baseURI(URI baseURI) { + this.baseURI = baseURI; return this; } diff --git a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java index b62ca4367..62e891d3d 100644 --- a/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/StreamingDataSourceBuilder.java @@ -30,8 +30,8 @@ public abstract class StreamingDataSourceBuilder implements UpdateProcessorFacto */ public static final long DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS = 1000; - protected URI baseUri; - protected URI pollingBaseUri; + protected URI baseURI; + protected URI pollingBaseURI; protected long initialReconnectDelayMillis = DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; /** @@ -44,11 +44,11 @@ public abstract class StreamingDataSourceBuilder implements UpdateProcessorFacto *
  • You are connecting to a test server or a nonstandard endpoint for the LaunchDarkly service. * * - * @param baseUri the base URI of the streaming service; null to use the default + * @param baseURI the base URI of the streaming service; null to use the default * @return the builder */ - public StreamingDataSourceBuilder baseUri(URI baseUri) { - this.baseUri = baseUri; + public StreamingDataSourceBuilder baseURI(URI baseURI) { + this.baseURI = baseURI; return this; } @@ -74,15 +74,15 @@ public StreamingDataSourceBuilder initialReconnectDelayMillis(long initialReconn * Sets a custom base URI for special polling requests. *

    * Even in streaming mode, the SDK sometimes temporarily must do a polling request. You do not need to - * modify this property unless you are connecting to a test server or a nonstandard endpoing for the + * modify this property unless you are connecting to a test server or a nonstandard endpoint for the * LaunchDarkly service. If you are using the Relay Proxy, - * you only need to set {@link #baseUri(URI)}. + * you only need to set {@link #baseURI(URI)}. * - * @param pollingBaseUri the polling endpoint URI; null to use the default + * @param pollingBaseURI the polling endpoint URI; null to use the default * @return the builder */ - public StreamingDataSourceBuilder pollingBaseUri(URI pollingBaseUri) { - this.pollingBaseUri = pollingBaseUri; + public StreamingDataSourceBuilder pollingBaseURI(URI pollingBaseURI) { + this.pollingBaseURI = pollingBaseURI; return this; } } diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 4064863ff..cb0f61d92 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -54,7 +54,7 @@ public class DefaultEventProcessorTest { // flush when it is closed; in this case, it's closed implicitly by the try-with-resources block. private EventProcessorBuilder baseConfig(MockWebServer server) { - return sendEvents().baseUri(server.url("").uri()); + return sendEvents().baseURI(server.url("").uri()); } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { @@ -771,7 +771,7 @@ public void flushIsRetriedOnceAfter5xxError() throws Exception { @Test public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { - EventProcessorBuilder ec = sendEvents().baseUri(serverWithCert.uri()); + EventProcessorBuilder ec = sendEvents().baseURI(serverWithCert.uri()); try (DefaultEventProcessor ep = makeEventProcessor(ec)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); ep.sendEvent(e); @@ -787,7 +787,7 @@ public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { @Test public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { - EventProcessorBuilder ec = sendEvents().baseUri(serverWithCert.uri()); + EventProcessorBuilder ec = sendEvents().baseURI(serverWithCert.uri()); LDConfig config = new LDConfig.Builder() .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert .build(); diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index d5e549726..20fe513ef 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -114,8 +114,8 @@ public void testCustomDiagnosticConfigurationForStreaming() { LDConfig ldConfig = new LDConfig.Builder() .dataSource( Components.streamingDataSource() - .baseUri(URI.create("https://1.1.1.1")) - .pollingBaseUri(URI.create("https://1.1.1.1")) + .baseURI(URI.create("https://1.1.1.1")) + .pollingBaseURI(URI.create("https://1.1.1.1")) .initialReconnectDelayMillis(2_000) ) .build(); @@ -135,7 +135,7 @@ public void testCustomDiagnosticConfigurationForPolling() { LDConfig ldConfig = new LDConfig.Builder() .dataSource( Components.pollingDataSource() - .baseUri(URI.create("https://1.1.1.1")) + .baseURI(URI.create("https://1.1.1.1")) .pollIntervalMillis(60_000) ) .build(); diff --git a/src/test/java/com/launchdarkly/client/LDClientTest.java b/src/test/java/com/launchdarkly/client/LDClientTest.java index 57b61618e..caa1c37a0 100644 --- a/src/test/java/com/launchdarkly/client/LDClientTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientTest.java @@ -155,7 +155,7 @@ public void clientHasNullEventProcessorIfSendEventsIsFalse() throws IOException @Test public void streamingClientHasStreamProcessor() throws Exception { LDConfig config = new LDConfig.Builder() - .dataSource(Components.streamingDataSource().baseUri(URI.create("http://fake"))) + .dataSource(Components.streamingDataSource().baseURI(URI.create("http://fake"))) .startWaitMillis(0) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { @@ -166,7 +166,7 @@ public void streamingClientHasStreamProcessor() throws Exception { @Test public void pollingClientHasPollingProcessor() throws IOException { LDConfig config = new LDConfig.Builder() - .dataSource(Components.pollingDataSource().baseUri(URI.create("http://fake"))) + .dataSource(Components.pollingDataSource().baseURI(URI.create("http://fake"))) .startWaitMillis(0) .build(); try (LDClient client = new LDClient(SDK_KEY, config)) { diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 035ff814e..a6d308bbf 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -34,11 +34,11 @@ static ServerWithCert httpsServerWithSelfSignedCert(MockResponse... responses) t } static StreamingDataSourceBuilder baseStreamingConfig(MockWebServer server) { - return Components.streamingDataSource().baseUri(server.url("").uri()); + return Components.streamingDataSource().baseURI(server.url("").uri()); } static PollingDataSourceBuilder basePollingConfig(MockWebServer server) { - return Components.pollingDataSource().baseUri(server.url("").uri()); + return Components.pollingDataSource().baseURI(server.url("").uri()); } static MockResponse jsonResponse(String body) { From 087ceca8341ca01054c4fc1d29016e655ede95ad Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jan 2020 15:58:28 -0800 Subject: [PATCH 271/327] fix generation of CA certs for TLS tests --- src/test/java/com/launchdarkly/client/TestHttpUtil.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 035ff814e..7644fe43e 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -63,6 +63,7 @@ public ServerWithCert() throws IOException, GeneralSecurityException { cert = new HeldCertificate.Builder() .serialNumber("1") + .ca(1) .commonName(hostname) .subjectAlternativeName(hostname) .build(); From 25df733862694ad9691bc026c681c5eb59583271 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jan 2020 16:07:37 -0800 Subject: [PATCH 272/327] javadoc --- src/main/java/com/launchdarkly/client/LDConfig.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 2b331700c..ebb1be5bb 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -701,6 +701,7 @@ public Builder inlineUsersInEvents(boolean inlineUsersInEvents) { * * @param diagnosticOptOut true if you want to opt out of sending any diagnostics data * @return the builder + * @since 4.12.0 */ public Builder diagnosticOptOut(boolean diagnosticOptOut) { this.diagnosticOptOut = diagnosticOptOut; @@ -714,6 +715,7 @@ public Builder diagnosticOptOut(boolean diagnosticOptOut) { * * @param wrapperName an identifying name for the wrapper library * @return the builder + * @since 4.12.0 */ public Builder wrapperName(String wrapperName) { this.wrapperName = wrapperName; @@ -727,6 +729,7 @@ public Builder wrapperName(String wrapperName) { * * @param wrapperVersion Version string for the wrapper library * @return the builder + * @since 4.12.0 */ public Builder wrapperVersion(String wrapperVersion) { this.wrapperVersion = wrapperVersion; From e4c77db88793a58e7b85b98554bb31843c2a560c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jan 2020 21:50:09 -0800 Subject: [PATCH 273/327] fix setting Redis URI --- .../launchdarkly/client/integrations/RedisDataStoreBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index 655f4aa59..8292851c2 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -113,7 +113,7 @@ public RedisDataStoreBuilder tls(boolean tls) { * @return the builder */ public RedisDataStoreBuilder uri(URI redisUri) { - this.uri = checkNotNull(uri); + this.uri = checkNotNull(redisUri); return this; } From 0b8705450c90ee083cc9268d3d6cf670f091ab7f Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jan 2020 21:50:43 -0800 Subject: [PATCH 274/327] better data store tests using fake data model --- .../client/DataStoreTestTypes.java | 117 +++++++ .../client/FeatureStoreDatabaseTestBase.java | 66 ++-- .../client/FeatureStoreTestBase.java | 134 +++---- .../com/launchdarkly/client/TestUtil.java | 7 + .../PersistentDataStoreTestBase.java | 329 ++++++++++++++++++ ...st.java => RedisDataStoreBuilderTest.java} | 33 +- .../integrations/RedisDataStoreImplTest.java | 51 +++ .../integrations/RedisFeatureStoreTest.java | 69 ---- 8 files changed, 623 insertions(+), 183 deletions(-) create mode 100644 src/test/java/com/launchdarkly/client/DataStoreTestTypes.java create mode 100644 src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java rename src/test/java/com/launchdarkly/client/integrations/{RedisFeatureStoreBuilderTest.java => RedisDataStoreBuilderTest.java} (64%) create mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java delete mode 100644 src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java diff --git a/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java new file mode 100644 index 000000000..bd0595def --- /dev/null +++ b/src/test/java/com/launchdarkly/client/DataStoreTestTypes.java @@ -0,0 +1,117 @@ +package com.launchdarkly.client; + +import com.google.common.base.Objects; + +@SuppressWarnings("javadoc") +public class DataStoreTestTypes { + public static class TestItem implements VersionedData { + public final String name; + public final String key; + public final int version; + public final boolean deleted; + + public TestItem(String name, String key, int version) { + this(name, key, version, false); + } + + public TestItem(String name, String key, int version, boolean deleted) { + this.name = name; + this.key = key; + this.version = version; + this.deleted = deleted; + } + + @Override + public String getKey() { + return key; + } + + @Override + public int getVersion() { + return version; + } + + @Override + public boolean isDeleted() { + return deleted; + } + + public TestItem withName(String newName) { + return new TestItem(newName, key, version, deleted); + } + + public TestItem withVersion(int newVersion) { + return new TestItem(name, key, newVersion, deleted); + } + + public TestItem withDeleted(boolean newDeleted) { + return new TestItem(name, key, version, newDeleted); + } + + @Override + public boolean equals(Object other) { + if (other instanceof TestItem) { + TestItem o = (TestItem)other; + return Objects.equal(name, o.name) && + Objects.equal(key, o.key) && + version == o.version && + deleted == o.deleted; + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(name, key, version, deleted); + } + + @Override + public String toString() { + return "TestItem(" + name + "," + key + "," + version + "," + deleted + ")"; + } + } + + public static final VersionedDataKind TEST_ITEMS = new VersionedDataKind() { + @Override + public String getNamespace() { + return "test-items"; + } + + @Override + public Class getItemClass() { + return TestItem.class; + } + + @Override + public String getStreamApiPath() { + return null; + } + + @Override + public TestItem makeDeletedItem(String key, int version) { + return new TestItem(null, key, version, true); + } + }; + + public static final VersionedDataKind OTHER_TEST_ITEMS = new VersionedDataKind() { + @Override + public String getNamespace() { + return "other-test-items"; + } + + @Override + public Class getItemClass() { + return TestItem.class; + } + + @Override + public String getStreamApiPath() { + return null; + } + + @Override + public TestItem makeDeletedItem(String key, int version) { + return new TestItem(null, key, version, true); + } + }; +} diff --git a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java index e96278e11..83565442e 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreDatabaseTestBase.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.DataStoreTestTypes.TestItem; import com.launchdarkly.client.TestUtil.DataBuilder; import org.junit.After; @@ -13,7 +14,7 @@ import java.util.Arrays; import java.util.Map; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -25,6 +26,7 @@ * use the same underlying data store (i.e. database implementations in general). */ @RunWith(Parameterized.class) +@SuppressWarnings("javadoc") public abstract class FeatureStoreDatabaseTestBase extends FeatureStoreTestBase { @Parameters(name="cached={0}") @@ -101,7 +103,7 @@ public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { assertFalse(store.initialized()); - store2.init(new DataBuilder().add(FEATURES, feature1).build()); + store2.init(new DataBuilder().add(TEST_ITEMS, item1).build()); assertTrue(store.initialized()); } @@ -133,14 +135,13 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithLowerVersion() th final int store2VersionEnd = 4; int store1VersionEnd = 10; - final FeatureFlag flag1 = new FeatureFlagBuilder("foo").version(startVersion).build(); + final TestItem startItem = new TestItem("me", "foo", startVersion); 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); + store2.upsert(TEST_ITEMS, startItem.withVersion(versionCounter)); versionCounter++; } } @@ -149,12 +150,12 @@ public void run() { try { assumeTrue(setUpdateHook(store, concurrentModifier)); - store.init(new DataBuilder().add(FEATURES, flag1).build()); + store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - FeatureFlag store1End = new FeatureFlagBuilder(flag1).version(store1VersionEnd).build(); - store.upsert(FEATURES, store1End); + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsert(TEST_ITEMS, store1End); - FeatureFlag result = store.get(FEATURES, flag1.getKey()); + VersionedData result = store.get(TEST_ITEMS, startItem.key); assertEquals(store1VersionEnd, result.getVersion()); } finally { store2.close(); @@ -169,24 +170,23 @@ public void handlesUpsertRaceConditionAgainstExternalClientWithHigherVersion() t final int store2Version = 3; int store1VersionEnd = 2; - final FeatureFlag flag1 = new FeatureFlagBuilder("foo").version(startVersion).build(); + final TestItem startItem = new TestItem("me", "foo", startVersion); Runnable concurrentModifier = new Runnable() { public void run() { - FeatureFlag f = new FeatureFlagBuilder(flag1).version(store2Version).build(); - store2.upsert(FEATURES, f); + store2.upsert(TEST_ITEMS, startItem.withVersion(store2Version)); } }; try { assumeTrue(setUpdateHook(store, concurrentModifier)); - store.init(new DataBuilder().add(FEATURES, flag1).build()); + store.init(new DataBuilder().add(TEST_ITEMS, startItem).build()); - FeatureFlag store1End = new FeatureFlagBuilder(flag1).version(store1VersionEnd).build(); - store.upsert(FEATURES, store1End); + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsert(TEST_ITEMS, store1End); - FeatureFlag result = store.get(FEATURES, flag1.getKey()); + VersionedData result = store.get(TEST_ITEMS, startItem.key); assertEquals(store2Version, result.getVersion()); } finally { store2.close(); @@ -195,8 +195,6 @@ public void run() { @Test public void storesWithDifferentPrefixAreIndependent() throws Exception { - assumeFalse(cached); - T store1 = makeStoreWithPrefix("aaa"); Assume.assumeNotNull(store1); T store2 = makeStoreWithPrefix("bbb"); @@ -206,32 +204,32 @@ public void storesWithDifferentPrefixAreIndependent() throws Exception { 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(); + TestItem item1a = new TestItem("a1", "flag-a", 1); + TestItem item1b = new TestItem("b", "flag-b", 1); + TestItem item2a = new TestItem("a2", "flag-a", 2); + TestItem item2c = new TestItem("c", "flag-c", 2); - store1.init(new DataBuilder().add(FEATURES, flag1a, flag1b).build()); + store1.init(new DataBuilder().add(TEST_ITEMS, item1a, item1b).build()); assertTrue(store1.initialized()); assertFalse(store2.initialized()); - store2.init(new DataBuilder().add(FEATURES, flag2a, flag2c).build()); + store2.init(new DataBuilder().add(TEST_ITEMS, item2a, item2c).build()); assertTrue(store1.initialized()); assertTrue(store2.initialized()); - Map items1 = store1.all(FEATURES); - Map items2 = store2.all(FEATURES); + Map items1 = store1.all(TEST_ITEMS); + Map items2 = store2.all(TEST_ITEMS); 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()); + assertEquals(item1a, items1.get(item1a.key)); + assertEquals(item1b, items1.get(item1b.key)); + assertEquals(item2a, items2.get(item2a.key)); + assertEquals(item2c, items2.get(item2c.key)); - assertEquals(flag1a.getVersion(), store1.get(FEATURES, flag1a.getKey()).getVersion()); - assertEquals(flag1b.getVersion(), store1.get(FEATURES, flag1b.getKey()).getVersion()); - assertEquals(flag2a.getVersion(), store2.get(FEATURES, flag2a.getKey()).getVersion()); - assertEquals(flag2c.getVersion(), store2.get(FEATURES, flag2c.getKey()).getVersion()); + assertEquals(item1a, store1.get(TEST_ITEMS, item1a.key)); + assertEquals(item1b, store1.get(TEST_ITEMS, item1b.key)); + assertEquals(item2a, store2.get(TEST_ITEMS, item2a.key)); + assertEquals(item2c, store2.get(TEST_ITEMS, item2c.key)); } 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 5b3bdf2f4..d97917b58 100644 --- a/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java +++ b/src/test/java/com/launchdarkly/client/FeatureStoreTestBase.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.launchdarkly.client.DataStoreTestTypes.TestItem; import com.launchdarkly.client.TestUtil.DataBuilder; import org.junit.After; @@ -8,8 +9,8 @@ import java.util.Map; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; -import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; +import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; @@ -20,24 +21,17 @@ * Basic tests for FeatureStore implementations. For database implementations, use the more * comprehensive FeatureStoreDatabaseTestBase. */ +@SuppressWarnings("javadoc") public abstract class FeatureStoreTestBase { protected T store; protected boolean cached; + + protected TestItem item1 = new TestItem("first", "key1", 10); - protected FeatureFlag feature1 = new FeatureFlagBuilder("foo") - .version(10) - .salt("abc") - .build(); - - protected FeatureFlag feature2 = new FeatureFlagBuilder("bar") - .version(10) - .salt("abc") - .build(); + protected TestItem item2 = new TestItem("second", "key2", 10); - protected Segment segment1 = new Segment.Builder("foo") - .version(11) - .build(); + protected TestItem otherItem1 = new TestItem("other-first", "key1", 11); public FeatureStoreTestBase() { this(false); @@ -89,116 +83,98 @@ public void initCompletelyReplacesPreviousData() { clearAllData(); Map, Map> allData = - new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build(); + new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build(); store.init(allData); - FeatureFlag feature2v2 = new FeatureFlagBuilder(feature2).version(feature2.getVersion() + 1).build(); - allData = new DataBuilder().add(FEATURES, feature2v2).add(SEGMENTS).build(); + TestItem item2v2 = item2.withVersion(item2.version + 1); + allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).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())); + assertNull(store.get(TEST_ITEMS, item1.key)); + assertEquals(item2v2, store.get(TEST_ITEMS, item2.key)); + assertNull(store.get(OTHER_TEST_ITEMS, otherItem1.key)); } @Test - public void getExistingFeature() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag result = store.get(FEATURES, feature1.getKey()); - assertEquals(feature1.getKey(), result.getKey()); + public void getExistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + assertEquals(item1, store.get(TEST_ITEMS, item1.key)); } @Test - public void getNonexistingFeature() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - assertNull(store.get(FEATURES, "biz")); + public void getNonexistingItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + assertNull(store.get(TEST_ITEMS, "biz")); } @Test public void getAll() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).add(SEGMENTS, segment1).build()); - Map items = store.all(FEATURES); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).build()); + Map items = store.all(TEST_ITEMS); 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()); + assertEquals(item1, items.get(item1.key)); + assertEquals(item2, items.get(item2.key)); } @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); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + store.delete(TEST_ITEMS, item1.key, item1.getVersion() + 1); + Map items = store.all(TEST_ITEMS); assertEquals(1, items.size()); - FeatureFlag item2 = items.get(feature2.getKey()); - assertNotNull(item2); - assertEquals(feature2.getVersion(), item2.getVersion()); + assertEquals(item2, items.get(item2.key)); } @Test public void upsertWithNewerVersion() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag newVer = new FeatureFlagBuilder(feature1) - .version(feature1.getVersion() + 1) - .build(); - store.upsert(FEATURES, newVer); - FeatureFlag result = store.get(FEATURES, newVer.getKey()); - assertEquals(newVer.getVersion(), result.getVersion()); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem newVer = item1.withVersion(item1.version + 1); + store.upsert(TEST_ITEMS, newVer); + assertEquals(newVer, store.get(TEST_ITEMS, item1.key)); } @Test public void upsertWithOlderVersion() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag oldVer = new FeatureFlagBuilder(feature1) - .version(feature1.getVersion() - 1) - .build(); - store.upsert(FEATURES, oldVer); - FeatureFlag result = store.get(FEATURES, oldVer.getKey()); - assertEquals(feature1.getVersion(), result.getVersion()); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem oldVer = item1.withVersion(item1.version - 1); + store.upsert(TEST_ITEMS, oldVer); + assertEquals(item1, store.get(TEST_ITEMS, item1.key)); } @Test - public void upsertNewFeature() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - FeatureFlag newFeature = new FeatureFlagBuilder("biz") - .version(99) - .build(); - store.upsert(FEATURES, newFeature); - FeatureFlag result = store.get(FEATURES, newFeature.getKey()); - assertEquals(newFeature.getKey(), result.getKey()); + public void upsertNewItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + TestItem newItem = new TestItem("new-name", "new-key", 99); + store.upsert(TEST_ITEMS, newItem); + assertEquals(newItem, store.get(TEST_ITEMS, newItem.key)); } @Test public void deleteWithNewerVersion() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - store.delete(FEATURES, feature1.getKey(), feature1.getVersion() + 1); - assertNull(store.get(FEATURES, feature1.getKey())); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + store.delete(TEST_ITEMS, item1.key, item1.version + 1); + assertNull(store.get(TEST_ITEMS, item1.key)); } @Test public void deleteWithOlderVersion() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - store.delete(FEATURES, feature1.getKey(), feature1.getVersion() - 1); - assertNotNull(store.get(FEATURES, feature1.getKey())); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + store.delete(TEST_ITEMS, item1.key, item1.version - 1); + assertNotNull(store.get(TEST_ITEMS, item1.key)); } @Test - public void deleteUnknownFeature() { - store.init(new DataBuilder().add(FEATURES, feature1, feature2).build()); - store.delete(FEATURES, "biz", 11); - assertNull(store.get(FEATURES, "biz")); + public void deleteUnknownItem() { + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + store.delete(TEST_ITEMS, "biz", 11); + assertNull(store.get(TEST_ITEMS, "biz")); } @Test public void upsertOlderVersionAfterDelete() { - 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())); + store.init(new DataBuilder().add(TEST_ITEMS, item1, item2).build()); + store.delete(TEST_ITEMS, item1.key, item1.version + 1); + store.upsert(TEST_ITEMS, item1); + assertNull(store.get(TEST_ITEMS, item1.key)); } } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 5d2f5f729..82eb9dc03 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -207,6 +207,13 @@ public DataBuilder add(VersionedDataKind kind, VersionedData... items) { public Map, Map> build() { return data; } + + // Silly casting helper due to difference in generic signatures between FeatureStore and FeatureStoreCore + @SuppressWarnings({ "unchecked", "rawtypes" }) + public Map, Map> buildUnchecked() { + Map uncheckedMap = data; + return (Map, Map>)uncheckedMap; + } } public static EvaluationDetail simpleEvaluation(int variation, LDValue value) { diff --git a/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java new file mode 100644 index 000000000..569efb3d3 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/PersistentDataStoreTestBase.java @@ -0,0 +1,329 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.DataStoreTestTypes.TestItem; +import com.launchdarkly.client.TestUtil.DataBuilder; +import com.launchdarkly.client.VersionedData; +import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.utils.FeatureStoreCore; + +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.util.Map; + +import static com.launchdarkly.client.DataStoreTestTypes.OTHER_TEST_ITEMS; +import static com.launchdarkly.client.DataStoreTestTypes.TEST_ITEMS; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * Similar to FeatureStoreTestBase, but exercises only the underlying database implementation of a persistent + * data store. The caching behavior, which is entirely implemented by CachingStoreWrapper, is covered by + * CachingStoreWrapperTest. + */ +@SuppressWarnings("javadoc") +public abstract class PersistentDataStoreTestBase { + protected T store; + + protected TestItem item1 = new TestItem("first", "key1", 10); + + protected TestItem item2 = new TestItem("second", "key2", 10); + + protected TestItem otherItem1 = new TestItem("other-first", "key1", 11); + + /** + * Test subclasses must override this method to create an instance of the feature store class + * with default properties. + */ + protected abstract T makeStore(); + + /** + * Test subclasses should implement this if the feature store class supports a key prefix option + * for keeping data sets distinct within the same database. + */ + protected abstract T makeStoreWithPrefix(String prefix); + + /** + * Test classes should override this to clear all data from the underlying database. + */ + protected abstract 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() { + store = makeStore(); + } + + @After + public void teardown() throws Exception { + store.close(); + } + + @Test + public void storeNotInitializedBeforeInit() { + clearAllData(); + assertFalse(store.initializedInternal()); + } + + @Test + public void storeInitializedAfterInit() { + store.initInternal(new DataBuilder().buildUnchecked()); + assertTrue(store.initializedInternal()); + } + + @Test + public void initCompletelyReplacesPreviousData() { + clearAllData(); + + Map, Map> allData = + new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildUnchecked(); + store.initInternal(allData); + + TestItem item2v2 = item2.withVersion(item2.version + 1); + allData = new DataBuilder().add(TEST_ITEMS, item2v2).add(OTHER_TEST_ITEMS).buildUnchecked(); + store.initInternal(allData); + + assertNull(store.getInternal(TEST_ITEMS, item1.key)); + assertEquals(item2v2, store.getInternal(TEST_ITEMS, item2.key)); + assertNull(store.getInternal(OTHER_TEST_ITEMS, otherItem1.key)); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStore() { + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.initializedInternal()); + + store2.initInternal(new DataBuilder().add(TEST_ITEMS, item1).buildUnchecked()); + + assertTrue(store.initializedInternal()); + } + + @Test + public void oneInstanceCanDetectIfAnotherInstanceHasInitializedTheStoreEvenIfEmpty() { + clearAllData(); + T store2 = makeStore(); + + assertFalse(store.initializedInternal()); + + store2.initInternal(new DataBuilder().buildUnchecked()); + + assertTrue(store.initializedInternal()); + } + + @Test + public void getExistingItem() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + assertEquals(item1, store.getInternal(TEST_ITEMS, item1.key)); + } + + @Test + public void getNonexistingItem() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + assertNull(store.getInternal(TEST_ITEMS, "biz")); + } + + @Test + public void getAll() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).add(OTHER_TEST_ITEMS, otherItem1).buildUnchecked()); + Map items = store.getAllInternal(TEST_ITEMS); + assertEquals(2, items.size()); + assertEquals(item1, items.get(item1.key)); + assertEquals(item2, items.get(item2.key)); + } + + @Test + public void getAllWithDeletedItem() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem deletedItem = item1.withVersion(item1.version + 1).withDeleted(true); + store.upsertInternal(TEST_ITEMS, deletedItem); + Map items = store.getAllInternal(TEST_ITEMS); + assertEquals(2, items.size()); + assertEquals(item2, items.get(item2.key)); + assertEquals(deletedItem, items.get(item1.key)); + } + + @Test + public void upsertWithNewerVersion() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem newVer = item1.withVersion(item1.version + 1).withName("modified"); + store.upsertInternal(TEST_ITEMS, newVer); + assertEquals(newVer, store.getInternal(TEST_ITEMS, item1.key)); + } + + @Test + public void upsertWithOlderVersion() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem oldVer = item1.withVersion(item1.version - 1).withName("modified"); + store.upsertInternal(TEST_ITEMS, oldVer); + assertEquals(item1, store.getInternal(TEST_ITEMS, oldVer.key)); + } + + @Test + public void upsertNewItem() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem newItem = new TestItem("new-name", "new-key", 99); + store.upsertInternal(TEST_ITEMS, newItem); + assertEquals(newItem, store.getInternal(TEST_ITEMS, newItem.key)); + } + + @Test + public void deleteWithNewerVersion() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem deletedItem = item1.withVersion(item1.version + 1).withDeleted(true); + store.upsertInternal(TEST_ITEMS, deletedItem); + assertEquals(deletedItem, store.getInternal(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteWithOlderVersion() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem deletedItem = item1.withVersion(item1.version - 1).withDeleted(true); + store.upsertInternal(TEST_ITEMS, deletedItem); + assertEquals(item1, store.getInternal(TEST_ITEMS, item1.key)); + } + + @Test + public void deleteUnknownItem() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem deletedItem = new TestItem(null, "deleted-key", 11, true); + store.upsertInternal(TEST_ITEMS, deletedItem); + assertEquals(deletedItem, store.getInternal(TEST_ITEMS, deletedItem.key)); + } + + @Test + public void upsertOlderVersionAfterDelete() { + store.initInternal(new DataBuilder().add(TEST_ITEMS, item1, item2).buildUnchecked()); + TestItem deletedItem = item1.withVersion(item1.version + 1).withDeleted(true); + store.upsertInternal(TEST_ITEMS, deletedItem); + store.upsertInternal(TEST_ITEMS, item1); + assertEquals(deletedItem, store.getInternal(TEST_ITEMS, item1.key)); + } + + // 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 TestItem startItem = new TestItem("me", "foo", startVersion); + + Runnable concurrentModifier = new Runnable() { + int versionCounter = store2VersionStart; + public void run() { + if (versionCounter <= store2VersionEnd) { + store2.upsertInternal(TEST_ITEMS, startItem.withVersion(versionCounter)); + versionCounter++; + } + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.initInternal(new DataBuilder().add(TEST_ITEMS, startItem).buildUnchecked()); + + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsertInternal(TEST_ITEMS, store1End); + + VersionedData result = store.getInternal(TEST_ITEMS, startItem.key); + 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 TestItem startItem = new TestItem("me", "foo", startVersion); + + Runnable concurrentModifier = new Runnable() { + public void run() { + store2.upsertInternal(TEST_ITEMS, startItem.withVersion(store2Version)); + } + }; + + try { + assumeTrue(setUpdateHook(store, concurrentModifier)); + + store.initInternal(new DataBuilder().add(TEST_ITEMS, startItem).buildUnchecked()); + + TestItem store1End = startItem.withVersion(store1VersionEnd); + store.upsertInternal(TEST_ITEMS, store1End); + + VersionedData result = store.getInternal(TEST_ITEMS, startItem.key); + assertEquals(store2Version, result.getVersion()); + } finally { + store2.close(); + } + } + + @Test + public void storesWithDifferentPrefixAreIndependent() throws Exception { + T store1 = makeStoreWithPrefix("aaa"); + Assume.assumeNotNull(store1); + T store2 = makeStoreWithPrefix("bbb"); + clearAllData(); + + try { + assertFalse(store1.initializedInternal()); + assertFalse(store2.initializedInternal()); + + TestItem item1a = new TestItem("a1", "flag-a", 1); + TestItem item1b = new TestItem("b", "flag-b", 1); + TestItem item2a = new TestItem("a2", "flag-a", 2); + TestItem item2c = new TestItem("c", "flag-c", 2); + + store1.initInternal(new DataBuilder().add(TEST_ITEMS, item1a, item1b).buildUnchecked()); + assertTrue(store1.initializedInternal()); + assertFalse(store2.initializedInternal()); + + store2.initInternal(new DataBuilder().add(TEST_ITEMS, item2a, item2c).buildUnchecked()); + assertTrue(store1.initializedInternal()); + assertTrue(store2.initializedInternal()); + + Map items1 = store1.getAllInternal(TEST_ITEMS); + Map items2 = store2.getAllInternal(TEST_ITEMS); + assertEquals(2, items1.size()); + assertEquals(2, items2.size()); + assertEquals(item1a, items1.get(item1a.key)); + assertEquals(item1b, items1.get(item1b.key)); + assertEquals(item2a, items2.get(item2a.key)); + assertEquals(item2c, items2.get(item2c.key)); + + assertEquals(item1a, store1.getInternal(TEST_ITEMS, item1a.key)); + assertEquals(item1b, store1.getInternal(TEST_ITEMS, item1b.key)); + assertEquals(item2a, store2.getInternal(TEST_ITEMS, item2a.key)); + assertEquals(item2c, store2.getInternal(TEST_ITEMS, item2c.key)); + } finally { + store1.close(); + store2.close(); + } + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java similarity index 64% rename from src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java rename to src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java index 3da39b69b..7d14ccdb8 100644 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreBuilderTest.java +++ b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreBuilderTest.java @@ -2,27 +2,58 @@ import org.junit.Test; +import java.net.URI; import java.net.URISyntaxException; import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import redis.clients.jedis.JedisPoolConfig; import redis.clients.jedis.Protocol; @SuppressWarnings("javadoc") -public class RedisFeatureStoreBuilderTest { +public class RedisDataStoreBuilderTest { @Test public void testDefaultValues() { RedisDataStoreBuilder conf = Redis.dataStore(); assertEquals(RedisDataStoreBuilder.DEFAULT_URI, conf.uri); + assertNull(conf.database); + assertNull(conf.password); + assertFalse(conf.tls); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.connectTimeout); assertEquals(Protocol.DEFAULT_TIMEOUT, conf.socketTimeout); assertEquals(RedisDataStoreBuilder.DEFAULT_PREFIX, conf.prefix); assertNull(conf.poolConfig); } + @Test + public void testUriConfigured() { + URI uri = URI.create("redis://other:9999"); + RedisDataStoreBuilder conf = Redis.dataStore().uri(uri); + assertEquals(uri, conf.uri); + } + + @Test + public void testDatabaseConfigured() { + RedisDataStoreBuilder conf = Redis.dataStore().database(3); + assertEquals(new Integer(3), conf.database); + } + + @Test + public void testPasswordConfigured() { + RedisDataStoreBuilder conf = Redis.dataStore().password("secret"); + assertEquals("secret", conf.password); + } + + @Test + public void testTlsConfigured() { + RedisDataStoreBuilder conf = Redis.dataStore().tls(true); + assertTrue(conf.tls); + } + @Test public void testPrefixConfigured() throws URISyntaxException { RedisDataStoreBuilder conf = Redis.dataStore().prefix("prefix"); diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java new file mode 100644 index 000000000..f217b3347 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/RedisDataStoreImplTest.java @@ -0,0 +1,51 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; + +import org.junit.BeforeClass; + +import java.net.URI; + +import static org.junit.Assume.assumeTrue; + +import redis.clients.jedis.Jedis; + +@SuppressWarnings("javadoc") +public class RedisDataStoreImplTest extends PersistentDataStoreTestBase { + + private static final URI REDIS_URI = URI.create("redis://localhost:6379"); + + @BeforeClass + public static void maybeSkipDatabaseTests() { + String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); + assumeTrue(skipParam == null || skipParam.equals("")); + } + + @Override + protected RedisDataStoreImpl makeStore() { + return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).createPersistentDataStore(); + } + + @Override + protected RedisDataStoreImpl makeStoreWithPrefix(String prefix) { + return (RedisDataStoreImpl)Redis.dataStore().uri(REDIS_URI).prefix(prefix).createPersistentDataStore(); + } + + @Override + protected void clearAllData() { + try (Jedis client = new Jedis("localhost")) { + client.flushDB(); + } + } + + @Override + protected boolean setUpdateHook(RedisDataStoreImpl storeUnderTest, final Runnable hook) { + storeUnderTest.setUpdateListener(new UpdateListener() { + @Override + public void aboutToUpdate(String baseKey, String itemKey) { + hook.run(); + } + }); + return true; + } +} diff --git a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java b/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java deleted file mode 100644 index b93718a2b..000000000 --- a/src/test/java/com/launchdarkly/client/integrations/RedisFeatureStoreTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.launchdarkly.client.integrations; - -import com.launchdarkly.client.Components; -import com.launchdarkly.client.FeatureStore; -import com.launchdarkly.client.FeatureStoreDatabaseTestBase; -import com.launchdarkly.client.integrations.RedisDataStoreImpl.UpdateListener; -import com.launchdarkly.client.utils.CachingStoreWrapper; - -import org.junit.BeforeClass; - -import java.net.URI; - -import static org.junit.Assume.assumeTrue; - -import redis.clients.jedis.Jedis; - -@SuppressWarnings({ "deprecation", "javadoc" }) -public class RedisFeatureStoreTest extends FeatureStoreDatabaseTestBase { - - private static final URI REDIS_URI = URI.create("redis://localhost:6379"); - - public RedisFeatureStoreTest(boolean cached) { - super(cached); - } - - @BeforeClass - public static void maybeSkipDatabaseTests() { - String skipParam = System.getenv("LD_SKIP_DATABASE_TESTS"); - assumeTrue(skipParam == null || skipParam.equals("")); - } - - @Override - protected FeatureStore makeStore() { - RedisDataStoreBuilder redisBuilder = Redis.dataStore().uri(REDIS_URI); - PersistentDataStoreBuilder builder = Components.persistentDataStore(redisBuilder); - if (cached) { - builder.cacheSeconds(30); - } else { - builder.noCaching(); - } - return builder.createFeatureStore(); - } - - @Override - protected FeatureStore makeStoreWithPrefix(String prefix) { - return Components.persistentDataStore( - Redis.dataStore().uri(REDIS_URI).prefix(prefix) - ).noCaching().createFeatureStore(); - } - - @Override - protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { - client.flushDB(); - } - } - - @Override - protected boolean setUpdateHook(FeatureStore storeUnderTest, final Runnable hook) { - RedisDataStoreImpl core = (RedisDataStoreImpl)((CachingStoreWrapper)storeUnderTest).getCore(); - core.setUpdateListener(new UpdateListener() { - @Override - public void aboutToUpdate(String baseKey, String itemKey) { - hook.run(); - } - }); - return true; - } -} From 617cd48b9177ed244b685f1efbac1ade0bc065da Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jan 2020 22:00:36 -0800 Subject: [PATCH 275/327] add test for unwanted constant munging --- .../test-app/src/main/java/testapp/TestApp.java | 11 +++++++++++ 1 file changed, 11 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 200b2f4e2..bfec1bfdb 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestApp.java +++ b/packaging-test/test-app/src/main/java/testapp/TestApp.java @@ -1,6 +1,7 @@ package testapp; import com.launchdarkly.client.*; +import com.launchdarkly.client.integrations.*; import com.google.gson.*; import org.slf4j.*; @@ -8,6 +9,16 @@ public class TestApp { private static final Logger logger = LoggerFactory.getLogger(TestApp.class); public static void main(String[] args) throws Exception { + // Verify that our Redis URI constant is what it should be (test for ch63221) + if (!RedisDataStoreBuilder.DEFAULT_URI.toString().equals("redis://localhost:6379")) { + System.out.println("*** error: RedisDataStoreBuilder.DEFAULT_URI is " + RedisDataStoreBuilder.DEFAULT_URI); + System.exit(1); + } + if (!RedisFeatureStoreBuilder.DEFAULT_URI.toString().equals("redis://localhost:6379")) { + System.out.println("*** error: RedisFeatureStoreBuilder.DEFAULT_URI is " + RedisFeatureStoreBuilder.DEFAULT_URI); + System.exit(1); + } + LDConfig config = new LDConfig.Builder() .offline(true) .build(); From 8ff44521d54e0734605272cbe84d9846bf10dfce Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 28 Jan 2020 23:31:05 -0800 Subject: [PATCH 276/327] compute default Redis URL to avoid munging of string literals --- .../launchdarkly/client/RedisFeatureStoreBuilder.java | 4 ++-- .../client/integrations/RedisDataStoreBuilder.java | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java index 61f72f01f..60a45f94e 100644 --- a/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/RedisFeatureStoreBuilder.java @@ -27,13 +27,13 @@ public final class RedisFeatureStoreBuilder implements FeatureStoreFactory, Diag * The default value for the Redis URI: {@code redis://localhost:6379} * @since 4.0.0 */ - public static final URI DEFAULT_URI = URI.create("redis://localhost:6379"); + public static final URI DEFAULT_URI = RedisDataStoreBuilder.DEFAULT_URI; /** * The default value for {@link #prefix(String)}. * @since 4.0.0 */ - public static final String DEFAULT_PREFIX = "launchdarkly"; + public static final String DEFAULT_PREFIX = RedisDataStoreBuilder.DEFAULT_PREFIX; /** * The default value for {@link #cacheTime(long, TimeUnit)} (in seconds). diff --git a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java index 655f4aa59..692c68ef6 100644 --- a/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/RedisDataStoreBuilder.java @@ -1,5 +1,6 @@ package com.launchdarkly.client.integrations; +import com.google.common.base.Joiner; import com.launchdarkly.client.LDConfig; import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; @@ -43,7 +44,7 @@ public final class RedisDataStoreBuilder implements PersistentDataStoreFactory, /** * The default value for the Redis URI: {@code redis://localhost:6379} */ - public static final URI DEFAULT_URI = URI.create("redis://localhost:6379"); + public static final URI DEFAULT_URI = makeDefaultRedisURI(); /** * The default value for {@link #prefix(String)}. @@ -59,6 +60,13 @@ public final class RedisDataStoreBuilder implements PersistentDataStoreFactory, boolean tls = false; JedisPoolConfig poolConfig = null; + private static URI makeDefaultRedisURI() { + // This ungainly logic is a workaround for the overly aggressive behavior of the Shadow plugin, which + // wants to transform any string literal starting with "redis" because the root package of Jedis is + // "redis". + return URI.create(Joiner.on("").join("r", "e", "d", "i", "s") + "://localhost:6379"); + } + // These constructors are called only from Implementations RedisDataStoreBuilder() { } From b670c79f879ea34b9c45381c8f471c07841a069b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 30 Jan 2020 13:55:57 -0800 Subject: [PATCH 277/327] fix deprecated config logic so samplingInterval is preserved --- .../com/launchdarkly/client/Components.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 751e40692..3d7d94e5c 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -334,20 +334,26 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config) { public EventProcessor createEventProcessor(String sdkKey, LDConfig config, DiagnosticAccumulator diagnosticAccumulator) { - if (config.deprecatedSendEvents && !config.offline) { - EventProcessorFactory epf = sendEvents() - .allAttributesPrivate(config.deprecatedAllAttributesPrivate) - .baseURI(config.deprecatedEventsURI) - .capacity(config.deprecatedCapacity) - .flushIntervalSeconds(config.deprecatedFlushInterval) - .inlineUsersInEvents(config.deprecatedInlineUsersInEvents) - .privateAttributeNames(config.deprecatedPrivateAttrNames.toArray(new String[config.deprecatedPrivateAttrNames.size()])) - .userKeysCapacity(config.deprecatedUserKeysCapacity) - .userKeysFlushIntervalSeconds(config.deprecatedUserKeysFlushInterval); - return ((EventProcessorFactoryWithDiagnostics)epf).createEventProcessor(sdkKey, config, diagnosticAccumulator); - } else { + if (config.offline || !config.deprecatedSendEvents) { return new NullEventProcessor(); } + return new DefaultEventProcessor(sdkKey, + config, + new EventsConfiguration( + config.deprecatedAllAttributesPrivate, + config.deprecatedCapacity, + config.deprecatedEventsURI == null ? LDConfig.DEFAULT_EVENTS_URI : config.deprecatedEventsURI, + config.deprecatedFlushInterval, + config.deprecatedInlineUsersInEvents, + config.deprecatedPrivateAttrNames, + config.deprecatedSamplingInterval, + config.deprecatedUserKeysCapacity, + config.deprecatedUserKeysFlushInterval, + EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS + ), + config.httpConfig, + diagnosticAccumulator + ); } @Override From a2159ea05512b47b69fe9ed0a493a378ccd233a7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 30 Jan 2020 13:56:54 -0800 Subject: [PATCH 278/327] add tests to verify the config options that are actually passed to the components --- .../client/DefaultEventProcessor.java | 5 +- .../client/DefaultFeatureRequestor.java | 3 +- .../launchdarkly/client/PollingProcessor.java | 9 +- .../launchdarkly/client/StreamProcessor.java | 7 +- .../client/DefaultEventProcessorTest.java | 118 ++++++++++++++++++ .../client/PollingProcessorTest.java | 58 +++++++++ .../client/StreamProcessorTest.java | 61 +++++++++ 7 files changed, 252 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index 7c49a4a6b..d516a9bcc 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -47,6 +47,7 @@ final class DefaultEventProcessor implements EventProcessor { private static final String EVENT_SCHEMA_VERSION = "3"; private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; + @VisibleForTesting final EventDispatcher dispatcher; private final BlockingQueue inbox; private final ScheduledExecutorService scheduler; private final AtomicBoolean closed = new AtomicBoolean(false); @@ -63,7 +64,7 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - new EventDispatcher(sdkKey, config, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator); + dispatcher = new EventDispatcher(sdkKey, config, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator); Runnable flusher = new Runnable() { public void run() { @@ -204,7 +205,7 @@ static final class EventDispatcher { private static final int MAX_FLUSH_THREADS = 5; private static final int MESSAGE_BATCH_SIZE = 50; - private final EventsConfiguration eventsConfig; + @VisibleForTesting final EventsConfiguration eventsConfig; private final OkHttpClient httpClient; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index 24c0e00e4..e9d59fd7b 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.annotations.VisibleForTesting; import com.google.common.io.Files; import org.slf4j.Logger; @@ -34,7 +35,7 @@ final class DefaultFeatureRequestor implements FeatureRequestor { private static final String GET_LATEST_ALL_PATH = "/sdk/latest-all"; private static final long MAX_HTTP_CACHE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB - private final URI baseUri; + @VisibleForTesting final URI baseUri; private final OkHttpClient httpClient; private final Headers headers; private final boolean useCache; diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 6cc20cb87..7e97aa3b8 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; @@ -20,9 +21,9 @@ final class PollingProcessor implements UpdateProcessor { private static final Logger logger = LoggerFactory.getLogger(PollingProcessor.class); - private final FeatureRequestor requestor; + @VisibleForTesting final FeatureRequestor requestor; private final FeatureStore store; - private final long pollIntervalMillis; + @VisibleForTesting final long pollIntervalMillis; private AtomicBoolean initialized = new AtomicBoolean(false); private ScheduledExecutorService scheduler = null; @@ -40,7 +41,9 @@ public boolean initialized() { @Override public void close() throws IOException { logger.info("Closing LaunchDarkly PollingProcessor"); - scheduler.shutdown(); + if (scheduler != null) { + scheduler.shutdown(); + } requestor.close(); } diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 3e43706fb..764421a69 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; import com.google.gson.Gson; import com.google.gson.JsonElement; @@ -39,9 +40,9 @@ final class StreamProcessor implements UpdateProcessor { private final FeatureStore store; private final HttpConfiguration httpConfig; private final Headers headers; - private final URI streamUri; - private final long initialReconnectDelayMillis; - private final FeatureRequestor requestor; + @VisibleForTesting final URI streamUri; + @VisibleForTesting final long initialReconnectDelayMillis; + @VisibleForTesting final FeatureRequestor requestor; private final DiagnosticAccumulator diagnosticAccumulator; private final EventSourceCreator eventSourceCreator; private volatile EventSource es; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index cb0f61d92..61b4c0f8c 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -10,6 +11,7 @@ import org.hamcrest.Matchers; import org.junit.Test; +import java.net.URI; import java.text.SimpleDateFormat; import java.util.Date; import java.util.UUID; @@ -25,6 +27,7 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; @@ -70,6 +73,121 @@ private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec, Diagn diagLDConfig, diagnosticAccumulator); } + @Test + public void builderHasDefaultConfiguration() throws Exception { + EventProcessorFactory epf = Components.sendEvents(); + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + EventsConfiguration ec = ep.dispatcher.eventsConfig; + assertThat(ec.allAttributesPrivate, is(false)); + assertThat(ec.capacity, equalTo(EventProcessorBuilder.DEFAULT_CAPACITY)); + assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS)); + assertThat(ec.eventsUri, equalTo(LDConfig.DEFAULT_EVENTS_URI)); + assertThat(ec.flushIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL_SECONDS)); + assertThat(ec.inlineUsersInEvents, is(false)); + assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of())); + assertThat(ec.samplingInterval, equalTo(0)); + assertThat(ec.userKeysCapacity, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_CAPACITY)); + assertThat(ec.userKeysFlushIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS)); + } + } + + @Test + public void builderCanSpecifyConfiguration() throws Exception { + URI uri = URI.create("http://fake"); + EventProcessorFactory epf = Components.sendEvents() + .allAttributesPrivate(true) + .baseURI(uri) + .capacity(3333) + .diagnosticRecordingIntervalSeconds(480) + .flushIntervalSeconds(99) + .privateAttributeNames("cats", "dogs") + .userKeysCapacity(555) + .userKeysFlushIntervalSeconds(101); + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + EventsConfiguration ec = ep.dispatcher.eventsConfig; + assertThat(ec.allAttributesPrivate, is(true)); + assertThat(ec.capacity, equalTo(3333)); + assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(480)); + assertThat(ec.eventsUri, equalTo(uri)); + assertThat(ec.flushIntervalSeconds, equalTo(99)); + assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below + assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of("cats", "dogs"))); + assertThat(ec.samplingInterval, equalTo(0)); // can only set this with the deprecated config API + assertThat(ec.userKeysCapacity, equalTo(555)); + assertThat(ec.userKeysFlushIntervalSeconds, equalTo(101)); + } + // Test inlineUsersInEvents separately to make sure it and the other boolean property (allAttributesPrivate) + // are really independently settable, since there's no way to distinguish between two true values + EventProcessorFactory epf1 = Components.sendEvents().inlineUsersInEvents(true); + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf1.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + EventsConfiguration ec = ep.dispatcher.eventsConfig; + assertThat(ec.allAttributesPrivate, is(false)); + assertThat(ec.inlineUsersInEvents, is(true)); + } + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedConfigurationIsUsedWhenBuilderIsNotUsed() throws Exception { + URI uri = URI.create("http://fake"); + LDConfig config = new LDConfig.Builder() + .allAttributesPrivate(true) + .capacity(3333) + .eventsURI(uri) + .flushInterval(99) + .privateAttributeNames("cats", "dogs") + .samplingInterval(7) + .userKeysCapacity(555) + .userKeysFlushInterval(101) + .build(); + EventProcessorFactory epf = Components.defaultEventProcessor(); + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, config)) { + EventsConfiguration ec = ep.dispatcher.eventsConfig; + assertThat(ec.allAttributesPrivate, is(true)); + assertThat(ec.capacity, equalTo(3333)); + assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS)); + // can't set diagnosticRecordingIntervalSeconds with deprecated API, must use builder + assertThat(ec.eventsUri, equalTo(uri)); + assertThat(ec.flushIntervalSeconds, equalTo(99)); + assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below + assertThat(ec.privateAttrNames, equalTo(ImmutableSet.of("cats", "dogs"))); + assertThat(ec.samplingInterval, equalTo(7)); + assertThat(ec.userKeysCapacity, equalTo(555)); + assertThat(ec.userKeysFlushIntervalSeconds, equalTo(101)); + } + // Test inlineUsersInEvents separately to make sure it and the other boolean property (allAttributesPrivate) + // are really independently settable, since there's no way to distinguish between two true values + LDConfig config1 = new LDConfig.Builder().inlineUsersInEvents(true).build(); + try (DefaultEventProcessor ep = (DefaultEventProcessor)epf.createEventProcessor(SDK_KEY, config1)) { + EventsConfiguration ec = ep.dispatcher.eventsConfig; + assertThat(ec.allAttributesPrivate, is(false)); + assertThat(ec.inlineUsersInEvents, is(true)); + } + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedConfigurationHasSameDefaultsAsBuilder() throws Exception { + EventProcessorFactory epf0 = Components.sendEvents(); + EventProcessorFactory epf1 = Components.defaultEventProcessor(); + try (DefaultEventProcessor ep0 = (DefaultEventProcessor)epf0.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + try (DefaultEventProcessor ep1 = (DefaultEventProcessor)epf1.createEventProcessor(SDK_KEY, LDConfig.DEFAULT)) { + EventsConfiguration ec0 = ep0.dispatcher.eventsConfig; + EventsConfiguration ec1 = ep1.dispatcher.eventsConfig; + assertThat(ec1.allAttributesPrivate, equalTo(ec0.allAttributesPrivate)); + assertThat(ec1.capacity, equalTo(ec0.capacity)); + assertThat(ec1.diagnosticRecordingIntervalSeconds, equalTo(ec1.diagnosticRecordingIntervalSeconds)); + assertThat(ec1.eventsUri, equalTo(ec0.eventsUri)); + assertThat(ec1.flushIntervalSeconds, equalTo(ec1.flushIntervalSeconds)); + assertThat(ec1.inlineUsersInEvents, equalTo(ec1.inlineUsersInEvents)); + assertThat(ec1.privateAttrNames, equalTo(ec1.privateAttrNames)); + assertThat(ec1.samplingInterval, equalTo(ec1.samplingInterval)); + assertThat(ec1.userKeysCapacity, equalTo(ec1.userKeysCapacity)); + assertThat(ec1.userKeysFlushIntervalSeconds, equalTo(ec1.userKeysFlushIntervalSeconds)); + } + } + } + @Test public void identifyEventIsQueued() throws Exception { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); diff --git a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java index f70663fb6..8a9eff959 100644 --- a/src/test/java/com/launchdarkly/client/PollingProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/PollingProcessorTest.java @@ -1,21 +1,79 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.PollingDataSourceBuilder; + import org.junit.Test; import java.io.IOException; +import java.net.URI; import java.util.HashMap; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class PollingProcessorTest { + private static final String SDK_KEY = "sdk-key"; private static final long LENGTHY_INTERVAL = 60000; + @Test + public void builderHasDefaultConfiguration() throws Exception { + UpdateProcessorFactory f = Components.pollingDataSource(); + try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); + assertThat(pp.pollIntervalMillis, equalTo(PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS)); + } + } + + @Test + public void builderCanSpecifyConfiguration() throws Exception { + URI uri = URI.create("http://fake"); + UpdateProcessorFactory f = Components.pollingDataSource() + .baseURI(uri) + .pollIntervalMillis(LENGTHY_INTERVAL); + try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(uri)); + assertThat(pp.pollIntervalMillis, equalTo(LENGTHY_INTERVAL)); + } + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedConfigurationIsUsedWhenBuilderIsNotUsed() throws Exception { + URI uri = URI.create("http://fake"); + LDConfig config = new LDConfig.Builder() + .baseURI(uri) + .pollingIntervalMillis(LENGTHY_INTERVAL) + .stream(false) + .build(); + UpdateProcessorFactory f = Components.defaultUpdateProcessor(); + try (PollingProcessor pp = (PollingProcessor)f.createUpdateProcessor(SDK_KEY, config, null)) { + assertThat(((DefaultFeatureRequestor)pp.requestor).baseUri, equalTo(uri)); + assertThat(pp.pollIntervalMillis, equalTo(LENGTHY_INTERVAL)); + } + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedConfigurationHasSameDefaultsAsBuilder() throws Exception { + UpdateProcessorFactory f0 = Components.pollingDataSource(); + UpdateProcessorFactory f1 = Components.defaultUpdateProcessor(); + LDConfig config = new LDConfig.Builder().stream(false).build(); + try (PollingProcessor pp0 = (PollingProcessor)f0.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + try (PollingProcessor pp1 = (PollingProcessor)f1.createUpdateProcessor(SDK_KEY, config, null)) { + assertThat(((DefaultFeatureRequestor)pp1.requestor).baseUri, + equalTo(((DefaultFeatureRequestor)pp0.requestor).baseUri)); + assertThat(pp1.pollIntervalMillis, equalTo(pp0.pollIntervalMillis)); + } + } + } + @Test public void testConnectionOk() throws Exception { MockFeatureRequestor requestor = new MockFeatureRequestor(); diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index f7b70e60a..9e5f6dc8d 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -1,5 +1,7 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.PollingDataSourceBuilder; +import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -27,6 +29,7 @@ import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.expect; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.junit.Assert.assertEquals; @@ -69,6 +72,64 @@ public void setup() { mockRequestor = createStrictMock(FeatureRequestor.class); mockEventSource = createStrictMock(EventSource.class); } + + @Test + public void builderHasDefaultConfiguration() throws Exception { + UpdateProcessorFactory f = Components.streamingDataSource(); + try (StreamProcessor sp = (StreamProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + assertThat(sp.initialReconnectDelayMillis, equalTo(StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS)); + assertThat(sp.streamUri, equalTo(LDConfig.DEFAULT_STREAM_URI)); + assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(LDConfig.DEFAULT_BASE_URI)); + } + } + + @Test + public void builderCanSpecifyConfiguration() throws Exception { + URI streamUri = URI.create("http://fake"); + URI pollUri = URI.create("http://also-fake"); + UpdateProcessorFactory f = Components.streamingDataSource() + .baseURI(streamUri) + .initialReconnectDelayMillis(5555) + .pollingBaseURI(pollUri); + try (StreamProcessor sp = (StreamProcessor)f.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + assertThat(sp.initialReconnectDelayMillis, equalTo(5555L)); + assertThat(sp.streamUri, equalTo(streamUri)); + assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); + } + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedConfigurationIsUsedWhenBuilderIsNotUsed() throws Exception { + URI streamUri = URI.create("http://fake"); + URI pollUri = URI.create("http://also-fake"); + LDConfig config = new LDConfig.Builder() + .baseURI(pollUri) + .reconnectTimeMs(5555) + .streamURI(streamUri) + .build(); + UpdateProcessorFactory f = Components.defaultUpdateProcessor(); + try (StreamProcessor sp = (StreamProcessor)f.createUpdateProcessor(SDK_KEY, config, null)) { + assertThat(sp.initialReconnectDelayMillis, equalTo(5555L)); + assertThat(sp.streamUri, equalTo(streamUri)); + assertThat(((DefaultFeatureRequestor)sp.requestor).baseUri, equalTo(pollUri)); + } + } + + @Test + @SuppressWarnings("deprecation") + public void deprecatedConfigurationHasSameDefaultsAsBuilder() throws Exception { + UpdateProcessorFactory f0 = Components.streamingDataSource(); + UpdateProcessorFactory f1 = Components.defaultUpdateProcessor(); + try (StreamProcessor sp0 = (StreamProcessor)f0.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + try (StreamProcessor sp1 = (StreamProcessor)f1.createUpdateProcessor(SDK_KEY, LDConfig.DEFAULT, null)) { + assertThat(sp1.initialReconnectDelayMillis, equalTo(sp0.initialReconnectDelayMillis)); + assertThat(sp1.streamUri, equalTo(sp0.streamUri)); + assertThat(((DefaultFeatureRequestor)sp1.requestor).baseUri, + equalTo(((DefaultFeatureRequestor)sp0.requestor).baseUri)); + } + } + } @Test public void streamUriHasCorrectEndpoint() { From d68208d97c94bdab3d56e5dc93b92ffaa26052bb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 30 Jan 2020 13:57:04 -0800 Subject: [PATCH 279/327] deprecations --- src/main/java/com/launchdarkly/client/LDConfig.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index ebb1be5bb..adba16f8d 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -212,11 +212,13 @@ public Builder baseURI(URI baseURI) { } /** - * Set the base URL of the LaunchDarkly analytics event server for this configuration. + * Deprecated method for setting the base URI of the LaunchDarkly analytics event service. * * @param eventsURI the events URL of the LaunchDarkly server for this configuration * @return the builder + * @deprecated Use @link {@link Components#sendEvents()} wtih {@link EventProcessorBuilder#baseURI(URI)}. */ + @Deprecated public Builder eventsURI(URI eventsURI) { this.eventsURI = eventsURI; return this; @@ -264,6 +266,7 @@ public Builder dataStore(FeatureStoreFactory factory) { * @return the builder * @deprecated Please use {@link #featureStoreFactory(FeatureStoreFactory)}. */ + @Deprecated public Builder featureStore(FeatureStore store) { this.featureStore = store; return this; From 2025af6a8312614596c18b1364cd1a89dae7beb3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 30 Jan 2020 18:46:20 -0800 Subject: [PATCH 280/327] attempt to fix releases --- .ldrelease/publish-docs.sh | 7 +++ .ldrelease/publish.sh | 7 +++ build.gradle | 91 +++++++++++++++++++------------------- 3 files changed, 60 insertions(+), 45 deletions(-) create mode 100755 .ldrelease/publish-docs.sh create mode 100755 .ldrelease/publish.sh diff --git a/.ldrelease/publish-docs.sh b/.ldrelease/publish-docs.sh new file mode 100755 index 000000000..81e1bb48b --- /dev/null +++ b/.ldrelease/publish-docs.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -ue + +# Publish to Github Pages +echo "Publishing to Github Pages" +./gradlew gitPublishPush diff --git a/.ldrelease/publish.sh b/.ldrelease/publish.sh new file mode 100755 index 000000000..a2e9637b3 --- /dev/null +++ b/.ldrelease/publish.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +set -ue + +# Publish to Sonatype +echo "Publishing to Sonatype" +./gradlew publishToSonatype closeAndReleaseRepository || { echo "Gradle publish/release failed" >&2; exit 1; } diff --git a/build.gradle b/build.gradle index f4c16c321..eb48dff85 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,26 @@ -apply plugin: 'java' -apply plugin: 'checkstyle' -apply plugin: 'maven-publish' -apply plugin: 'org.ajoberstar.github-pages' -apply plugin: 'signing' -apply plugin: 'idea' -apply plugin: 'com.github.johnrengelman.shadow' -apply plugin: 'io.codearte.nexus-staging' -configurations.all { - // check for updates every build for dependencies with: 'changing: true' - resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +buildscript { + repositories { + mavenCentral() + mavenLocal() + } + dependencies { + classpath "org.eclipse.virgo.util:org.eclipse.virgo.util.osgi.manifest:3.5.0.RELEASE" + classpath "org.osgi:osgi_R4_core:1.0" + } +} + +plugins { + id "java" + id "java-library" + id "checkstyle" + id "signing" + id "com.github.johnrengelman.shadow" version "4.0.4" + id "maven-publish" + id "de.marcphilipp.nexus-publish" version "0.3.0" + id "io.codearte.nexus-staging" version "0.21.2" + id "org.ajoberstar.git-publish" version "2.1.3" + id "idea" } repositories { @@ -19,6 +30,11 @@ repositories { mavenCentral() } +configurations.all { + // check for updates every build for dependencies with: 'changing: true' + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + allprojects { group = 'com.launchdarkly' version = "${version}" @@ -80,21 +96,6 @@ 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:4.0.4' - 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" - } -} - checkstyle { configFile file("${project.rootDir}/checkstyle.xml") } @@ -188,17 +189,6 @@ if (JavaVersion.current().isJava8Compatible()) { } } -githubPages { - repoUri = 'https://github.com/launchdarkly/java-server-sdk.git' - pages { - from javadoc - } - credentials { - username = githubUser - password = githubPassword - } -} - // 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() { @@ -341,6 +331,7 @@ idea { nexusStaging { packageGroup = "com.launchdarkly" + numberOfRetries = 40 // we've seen extremely long delays in closing repositories } def pomConfig = { @@ -405,14 +396,15 @@ publishing { } 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 - } + } +} + +nexusPublishing { + clientTimeout = java.time.Duration.ofMinutes(2) // we've seen extremely long delays in creating repositories + repositories { + sonatype { + username = ossrhUsername + password = ossrhPassword } } } @@ -431,3 +423,12 @@ task exportDependencies(type: Copy, dependsOn: compileJava) { into "packaging-test/temp/dependencies-all" from configurations.runtime.resolvedConfiguration.resolvedArtifacts.collect { it.file } } + +gitPublish { + repoUri = 'git@github.com:launchdarkly/java-server-sdk.git' + branch = 'gh-pages' + contents { + from javadoc + } + commitMessage = 'publishing javadocs' +} From b6d7e606339d1e0b0798f08edeff51b16cc0b2e8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 10:33:29 -0700 Subject: [PATCH 281/327] use Set instead of List for user targets --- .../java/com/launchdarkly/client/Clause.java | 17 +++ .../java/com/launchdarkly/client/Rule.java | 2 +- .../java/com/launchdarkly/client/Segment.java | 23 ++- .../java/com/launchdarkly/client/Target.java | 8 +- .../client/VariationOrRollout.java | 28 +++- .../launchdarkly/client/FeatureFlagTest.java | 140 +++++++++++++++++- 6 files changed, 197 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/Clause.java b/src/main/java/com/launchdarkly/client/Clause.java index 3b8278fbc..8efaefd0d 100644 --- a/src/main/java/com/launchdarkly/client/Clause.java +++ b/src/main/java/com/launchdarkly/client/Clause.java @@ -6,6 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Collection; import java.util.List; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; @@ -28,6 +29,22 @@ public Clause(String attribute, Operator op, List values, boolean negat this.negate = negate; } + String getAttribute() { + return attribute; + } + + Operator getOp() { + return op; + } + + Collection getValues() { + return values; + } + + boolean isNegate() { + return negate; + } + boolean matchesUserNoSegments(LDUser user) { LDValue userValue = user.getValueForEvaluation(attribute); if (userValue.isNull()) { diff --git a/src/main/java/com/launchdarkly/client/Rule.java b/src/main/java/com/launchdarkly/client/Rule.java index 3ba0da86d..49939348d 100644 --- a/src/main/java/com/launchdarkly/client/Rule.java +++ b/src/main/java/com/launchdarkly/client/Rule.java @@ -34,7 +34,7 @@ String getId() { return id; } - Iterable getClauses() { + List getClauses() { return clauses; } diff --git a/src/main/java/com/launchdarkly/client/Segment.java b/src/main/java/com/launchdarkly/client/Segment.java index 2febf6b65..872c2ada5 100644 --- a/src/main/java/com/launchdarkly/client/Segment.java +++ b/src/main/java/com/launchdarkly/client/Segment.java @@ -1,17 +1,16 @@ package com.launchdarkly.client; -import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.List; -import java.util.Map; - -import com.google.gson.reflect.TypeToken; +import java.util.Set; +@SuppressWarnings("deprecation") class Segment implements VersionedData { private String key; - private List included; - private List excluded; + private Set included; + private Set excluded; private String salt; private List rules; private int version; @@ -79,8 +78,8 @@ public boolean matchesUser(LDUser user) { public static class Builder { private String key; - private List included = new ArrayList<>(); - private List excluded = new ArrayList<>(); + private Set included = new HashSet<>(); + private Set excluded = new HashSet<>(); private String salt = ""; private List rules = new ArrayList<>(); private int version = 0; @@ -92,8 +91,8 @@ public Builder(String key) { public Builder(Segment from) { this.key = from.key; - this.included = new ArrayList<>(from.included); - this.excluded = new ArrayList<>(from.excluded); + this.included = new HashSet<>(from.included); + this.excluded = new HashSet<>(from.excluded); this.salt = from.salt; this.rules = new ArrayList<>(from.rules); this.version = from.version; @@ -105,12 +104,12 @@ public Segment build() { } public Builder included(Collection included) { - this.included = new ArrayList<>(included); + this.included = new HashSet<>(included); return this; } public Builder excluded(Collection excluded) { - this.excluded = new ArrayList<>(excluded); + this.excluded = new HashSet<>(excluded); return this; } diff --git a/src/main/java/com/launchdarkly/client/Target.java b/src/main/java/com/launchdarkly/client/Target.java index 57e6f6598..54eb154da 100644 --- a/src/main/java/com/launchdarkly/client/Target.java +++ b/src/main/java/com/launchdarkly/client/Target.java @@ -1,20 +1,20 @@ package com.launchdarkly.client; -import java.util.List; +import java.util.Set; class Target { - private List values; + private Set values; private int variation; // We need this so Gson doesn't complain in certain java environments that restrict unsafe allocation Target() {} - Target(List values, int variation) { + Target(Set values, int variation) { this.values = values; this.variation = variation; } - List getValues() { + Set getValues() { return values; } diff --git a/src/main/java/com/launchdarkly/client/VariationOrRollout.java b/src/main/java/com/launchdarkly/client/VariationOrRollout.java index ba7b53941..e3f0ad29b 100644 --- a/src/main/java/com/launchdarkly/client/VariationOrRollout.java +++ b/src/main/java/com/launchdarkly/client/VariationOrRollout.java @@ -25,6 +25,14 @@ class VariationOrRollout { this.rollout = rollout; } + Integer getVariation() { + return variation; + } + + Rollout getRollout() { + return 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) { @@ -75,7 +83,7 @@ private static String getBucketableStringValue(LDValue userValue) { } } - static class Rollout { + static final class Rollout { private List variations; private String bucketBy; @@ -86,9 +94,17 @@ static class Rollout { this.variations = variations; this.bucketBy = bucketBy; } + + List getVariations() { + return variations; + } + + String getBucketBy() { + return bucketBy; + } } - static class WeightedVariation { + static final class WeightedVariation { private int variation; private int weight; @@ -99,5 +115,13 @@ static class WeightedVariation { this.variation = variation; this.weight = weight; } + + int getVariation() { + return variation; + } + + int getWeight() { + return weight; + } } } diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index f5df08687..71791785c 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.gson.Gson; import com.google.gson.JsonElement; import com.launchdarkly.client.value.LDValue; @@ -11,13 +12,17 @@ import java.util.Arrays; import static com.launchdarkly.client.EvaluationDetail.fromValue; +import static com.launchdarkly.client.JsonHelpers.gsonInstance; import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; import static com.launchdarkly.client.TestUtil.fallthroughVariation; 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.assertSame; +import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class FeatureFlagTest { @@ -328,7 +333,7 @@ public void multipleLevelsOfPrerequisitesProduceMultipleEvents() throws Exceptio public void flagMatchesUserFromTargets() throws Exception { FeatureFlag f = new FeatureFlagBuilder("feature") .on(true) - .targets(Arrays.asList(new Target(Arrays.asList("whoever", "userkey"), 2))) + .targets(Arrays.asList(new Target(ImmutableSet.of("whoever", "userkey"), 2))) .fallthrough(fallthroughVariation(0)) .offVariation(1) .variations(LDValue.of("fall"), LDValue.of("off"), LDValue.of("on")) @@ -525,7 +530,36 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti FeatureFlag.EvalResult result = flag.evaluate(user, featureStore, EventFactory.DEFAULT); assertEquals(LDValue.of(false), result.getDetails().getValue()); } - + + @Test + public void flagIsDeserializedWithAllProperties() { + String json = flagWithAllPropertiesJson().toJsonString(); + FeatureFlag flag0 = gsonInstance().fromJson(json, FeatureFlag.class); + assertFlagHasAllProperties(flag0); + + FeatureFlag flag1 = gsonInstance().fromJson(gsonInstance().toJson(flag0), FeatureFlag.class); + assertFlagHasAllProperties(flag1); + } + + @Test + public void flagIsDeserializedWithMinimalProperties() { + String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); + FeatureFlag flag = gsonInstance().fromJson(json, FeatureFlag.class); + assertEquals("flag-key", flag.getKey()); + assertEquals(99, flag.getVersion()); + assertFalse(flag.isOn()); + assertNull(flag.getSalt()); + assertNull(flag.getTargets()); + assertNull(flag.getRules()); + assertNull(flag.getFallthrough()); + assertNull(flag.getOffVariation()); + assertNull(flag.getVariations()); + assertFalse(flag.isClientSide()); + assertFalse(flag.isTrackEvents()); + assertFalse(flag.isTrackEventsFallthrough()); + assertNull(flag.getDebugEventsUntilDate()); + } + private FeatureFlag featureFlagWithRules(String flagKey, Rule... rules) { return new FeatureFlagBuilder(flagKey) .on(true) @@ -540,4 +574,106 @@ private FeatureFlag segmentMatchBooleanFlag(String segmentKey) { Clause clause = new Clause("", Operator.segmentMatch, Arrays.asList(LDValue.of(segmentKey)), false); return booleanFlagWithClauses("flag", clause); } + + private LDValue flagWithAllPropertiesJson() { + return LDValue.buildObject() + .put("key", "flag-key") + .put("version", 99) + .put("on", true) + .put("prerequisites", LDValue.buildArray() + .build()) + .put("salt", "123") + .put("targets", LDValue.buildArray() + .add(LDValue.buildObject() + .put("variation", 1) + .put("values", LDValue.buildArray().add("key1").add("key2").build()) + .build()) + .build()) + .put("rules", LDValue.buildArray() + .add(LDValue.buildObject() + .put("id", "id0") + .put("trackEvents", true) + .put("variation", 2) + .put("clauses", LDValue.buildArray() + .add(LDValue.buildObject() + .put("attribute", "name") + .put("op", "in") + .put("values", LDValue.buildArray().add("Lucy").build()) + .put("negate", true) + .build()) + .build()) + .build()) + .add(LDValue.buildObject() + .put("id", "id1") + .put("rollout", LDValue.buildObject() + .put("variations", LDValue.buildArray() + .add(LDValue.buildObject() + .put("variation", 2) + .put("weight", 100000) + .build()) + .build()) + .put("bucketBy", "email") + .build()) + .build()) + .build()) + .put("fallthrough", LDValue.buildObject() + .put("variation", 1) + .build()) + .put("offVariation", 2) + .put("variations", LDValue.buildArray().add("a").add("b").add("c").build()) + .put("clientSide", true) + .put("trackEvents", true) + .put("trackEventsFallthrough", true) + .put("debugEventsUntilDate", 1000) + .build(); + } + + private void assertFlagHasAllProperties(FeatureFlag flag) { + assertEquals("flag-key", flag.getKey()); + assertEquals(99, flag.getVersion()); + assertTrue(flag.isOn()); + assertEquals("123", flag.getSalt()); + + assertNotNull(flag.getTargets()); + assertEquals(1, flag.getTargets().size()); + Target t0 = flag.getTargets().get(0); + assertEquals(1, t0.getVariation()); + assertEquals(ImmutableSet.of("key1", "key2"), t0.getValues()); + + assertNotNull(flag.getRules()); + assertEquals(2, flag.getRules().size()); + Rule r0 = flag.getRules().get(0); + assertEquals("id0", r0.getId()); + assertTrue(r0.isTrackEvents()); + assertEquals(new Integer(2), r0.getVariation()); + assertNull(r0.getRollout()); + + assertNotNull(r0.getClauses()); + Clause c0 = r0.getClauses().get(0); + assertEquals("name", c0.getAttribute()); + assertEquals(Operator.in, c0.getOp()); + assertEquals(ImmutableList.of(LDValue.of("Lucy")), c0.getValues()); + assertTrue(c0.isNegate()); + + Rule r1 = flag.getRules().get(1); + assertEquals("id1", r1.getId()); + assertFalse(r1.isTrackEvents()); + assertNull(r1.getVariation()); + assertNotNull(r1.getRollout()); + assertNotNull(r1.getRollout().getVariations()); + assertEquals(1, r1.getRollout().getVariations().size()); + assertEquals(2, r1.getRollout().getVariations().get(0).getVariation()); + assertEquals(100000, r1.getRollout().getVariations().get(0).getWeight()); + assertEquals("email", r1.getRollout().getBucketBy()); + + assertNotNull(flag.getFallthrough()); + assertEquals(new Integer(1), flag.getFallthrough().getVariation()); + assertNull(flag.getFallthrough().getRollout()); + assertEquals(new Integer(2), flag.getOffVariation()); + assertEquals(ImmutableList.of(LDValue.of("a"), LDValue.of("b"), LDValue.of("c")), flag.getVariations()); + assertTrue(flag.isClientSide()); + assertTrue(flag.isTrackEvents()); + assertTrue(flag.isTrackEventsFallthrough()); + assertEquals(new Long(1000), flag.getDebugEventsUntilDate()); + } } From a05df4b8405a34e6a5ea43a8df04fc82d62028b2 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 10:37:31 -0700 Subject: [PATCH 282/327] use contains() instead of iterating --- src/main/java/com/launchdarkly/client/FeatureFlag.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/FeatureFlag.java b/src/main/java/com/launchdarkly/client/FeatureFlag.java index 9fde3f072..2abe060a5 100644 --- a/src/main/java/com/launchdarkly/client/FeatureFlag.java +++ b/src/main/java/com/launchdarkly/client/FeatureFlag.java @@ -83,10 +83,8 @@ private EvaluationDetail evaluate(LDUser user, FeatureStore featureStor // Check to see if targets match if (targets != null) { for (Target target: targets) { - for (String v : target.getValues()) { - if (v.equals(user.getKey().stringValue())) { - return getVariation(target.getVariation(), EvaluationReason.targetMatch()); - } + if (target.getValues().contains(user.getKey().stringValue())) { + return getVariation(target.getVariation(), EvaluationReason.targetMatch()); } } } From 334a68d7e3540fc8b5ea81dcf4e00479f2d19d85 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 13:37:15 -0700 Subject: [PATCH 283/327] change in-memory store to use an immutable map and lockless reads --- .../client/InMemoryFeatureStore.java | 143 +++++++++--------- 1 file changed, 69 insertions(+), 74 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index 77acbe9fe..0e591ef9f 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -1,5 +1,6 @@ package com.launchdarkly.client; +import com.google.common.collect.ImmutableMap; import com.launchdarkly.client.interfaces.DiagnosticDescription; import com.launchdarkly.client.value.LDValue; @@ -9,7 +10,6 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; -import java.util.concurrent.locks.ReentrantReadWriteLock; /** * A thread-safe, versioned store for feature flags and related data based on a @@ -18,111 +18,106 @@ public class InMemoryFeatureStore implements FeatureStore, DiagnosticDescription { private static final Logger logger = LoggerFactory.getLogger(InMemoryFeatureStore.class); - private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - private final Map, Map> allData = new HashMap<>(); + private volatile ImmutableMap, Map> allData = ImmutableMap.of(); private volatile boolean initialized = false; + private Object writeLock = new Object(); @Override public T get(VersionedDataKind kind, String key) { - try { - lock.readLock().lock(); - Map items = allData.get(kind); - if (items == null) { - logger.debug("[get] no objects exist for \"{}\". Returning null", kind.getNamespace()); - return null; - } - Object o = items.get(key); - if (o == null) { - logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); - return null; - } - if (!kind.getItemClass().isInstance(o)) { - logger.warn("[get] Unexpected object class {} found for key: {} in \"{}\". Returning null", - o.getClass().getName(), key, kind.getNamespace()); - return null; - } - T item = kind.getItemClass().cast(o); - if (item.isDeleted()) { - logger.debug("[get] Key: {} has been deleted. Returning null", key); - return null; - } - logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); - return item; - } finally { - lock.readLock().unlock(); + Map items = allData.get(kind); + if (items == null) { + logger.debug("[get] no objects exist for \"{}\". Returning null", kind.getNamespace()); + return null; + } + Object o = items.get(key); + if (o == null) { + logger.debug("[get] Key: {} not found in \"{}\". Returning null", key, kind.getNamespace()); + return null; } + if (!kind.getItemClass().isInstance(o)) { + logger.warn("[get] Unexpected object class {} found for key: {} in \"{}\". Returning null", + o.getClass().getName(), key, kind.getNamespace()); + return null; + } + T item = kind.getItemClass().cast(o); + if (item.isDeleted()) { + logger.debug("[get] Key: {} has been deleted. Returning null", key); + return null; + } + logger.debug("[get] Key: {} with version: {} found in \"{}\".", key, item.getVersion(), kind.getNamespace()); + return item; } @Override public Map all(VersionedDataKind kind) { - try { - lock.readLock().lock(); - Map fs = new HashMap<>(); - Map items = allData.get(kind); - if (items != null) { - for (Map.Entry entry : items.entrySet()) { - if (!entry.getValue().isDeleted()) { - fs.put(entry.getKey(), kind.getItemClass().cast(entry.getValue())); - } + Map fs = new HashMap<>(); + Map items = allData.get(kind); + if (items != null) { + for (Map.Entry entry : items.entrySet()) { + if (!entry.getValue().isDeleted()) { + fs.put(entry.getKey(), kind.getItemClass().cast(entry.getValue())); } } - return fs; - } finally { - lock.readLock().unlock(); } + return fs; } @Override public void init(Map, Map> allData) { - try { - lock.writeLock().lock(); - this.allData.clear(); + synchronized (writeLock) { + ImmutableMap.Builder, Map> newData = ImmutableMap.builder(); for (Map.Entry, Map> entry: allData.entrySet()) { // 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())); + newData.put(entry.getKey(), ImmutableMap.copyOf(entry.getValue())); } - initialized = true; - } finally { - lock.writeLock().unlock(); + this.allData = newData.build(); // replaces the entire map atomically + this.initialized = true; } } @Override public void delete(VersionedDataKind kind, String key, int version) { - try { - lock.writeLock().lock(); - Map items = allData.get(kind); - if (items == null) { - items = new HashMap<>(); - allData.put(kind, items); - } - VersionedData item = items.get(key); - if (item == null || item.getVersion() < version) { - items.put(key, kind.makeDeletedItem(key, version)); - } - } finally { - lock.writeLock().unlock(); - } + upsert(kind, kind.makeDeletedItem(key, version)); } @Override public void upsert(VersionedDataKind kind, T item) { - try { - lock.writeLock().lock(); - Map items = (Map) allData.get(kind); - if (items == null) { - items = new HashMap<>(); - allData.put(kind, items); + String key = item.getKey(); + synchronized (writeLock) { + Map existingItems = this.allData.get(kind); + VersionedData oldItem = null; + if (existingItems != null) { + oldItem = existingItems.get(key); + if (oldItem.getVersion() >= item.getVersion()) { + return; + } } - VersionedData old = items.get(item.getKey()); - - if (old == null || old.getVersion() < item.getVersion()) { - items.put(item.getKey(), item); + // The following logic is necessary because ImmutableMap.Builder doesn't support overwriting an existing key + ImmutableMap.Builder, Map> newData = ImmutableMap.builder(); + for (Map.Entry, Map> e: this.allData.entrySet()) { + if (!e.getKey().equals(kind)) { + newData.put(e.getKey(), e.getValue()); + } + } + if (existingItems == null) { + newData.put(kind, ImmutableMap.of(key, item)); + } else { + ImmutableMap.Builder itemsBuilder = ImmutableMap.builder(); + if (oldItem == null) { + itemsBuilder.putAll(existingItems); + } else { + for (Map.Entry e: existingItems.entrySet()) { + if (!e.getKey().equals(key)) { + itemsBuilder.put(e.getKey(), e.getValue()); + } + } + } + itemsBuilder.put(key, item); + newData.put(kind, itemsBuilder.build()); } - } finally { - lock.writeLock().unlock(); + this.allData = newData.build(); // replaces the entire map atomically } } From 02d99228ce1ae9792d5efe06ebf3737cb7b331f0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 9 Mar 2020 13:57:51 -0700 Subject: [PATCH 284/327] null guard in upsert --- src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java index 0e591ef9f..d2937cc79 100644 --- a/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java +++ b/src/main/java/com/launchdarkly/client/InMemoryFeatureStore.java @@ -90,7 +90,7 @@ public void upsert(VersionedDataKind kind, T item) VersionedData oldItem = null; if (existingItems != null) { oldItem = existingItems.get(key); - if (oldItem.getVersion() >= item.getVersion()) { + if (oldItem != null && oldItem.getVersion() >= item.getVersion()) { return; } } From d0e94334b5ad978378684b1b790063179535accc Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 20 Mar 2020 13:27:37 -0700 Subject: [PATCH 285/327] update okhttp-eventsource version (for okhttp update) (#201) --- build.gradle | 5 ++-- .../client/DefaultEventProcessorTest.java | 2 +- .../client/FeatureRequestorTest.java | 2 +- .../client/LDClientEndToEndTest.java | 4 +-- .../client/StreamProcessorTest.java | 3 +- .../com/launchdarkly/client/TestHttpUtil.java | 29 +++++++++++-------- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/build.gradle b/build.gradle index eb48dff85..a598ec767 100644 --- a/build.gradle +++ b/build.gradle @@ -60,7 +60,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.10.0", + "com.launchdarkly:okhttp-eventsource:1.10.2", "org.yaml:snakeyaml:1.19", "redis.clients:jedis:2.9.0" ] @@ -74,7 +74,8 @@ libraries.external = [ // Add dependencies to "libraries.test" that are used only in unit tests. libraries.test = [ - "com.squareup.okhttp3:mockwebserver:3.10.0", + "com.squareup.okhttp3:mockwebserver:3.12.10", + "com.squareup.okhttp3:okhttp-tls:3.12.10", "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", "junit:junit:4.12", diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 61b4c0f8c..e652c3f7b 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -907,7 +907,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { EventProcessorBuilder ec = sendEvents().baseURI(serverWithCert.uri()); LDConfig config = new LDConfig.Builder() - .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert .build(); try (DefaultEventProcessor ep = makeEventProcessor(ec, config)) { diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index b5517c18f..6b8ad7646 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -178,7 +178,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { LDConfig config = new LDConfig.Builder() - .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert .build(); try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index 6ce44a29d..22e897712 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -70,7 +70,7 @@ public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(basePollingConfig(serverWithCert.server)) .events(noEvents()) - .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert .build(); try (LDClient client = new LDClient(sdkKey, config)) { @@ -126,7 +126,7 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(baseStreamingConfig(serverWithCert.server)) .events(noEvents()) - .sslSocketFactory(serverWithCert.sslClient.socketFactory, serverWithCert.sslClient.trustManager) // allows us to trust the self-signed cert + .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert .build(); try (LDClient client = new LDClient(sdkKey, config)) { diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 9e5f6dc8d..8b4460a16 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -1,6 +1,5 @@ package com.launchdarkly.client; -import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; @@ -469,7 +468,7 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { server.server.enqueue(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA)); LDConfig config = new LDConfig.Builder() - .sslSocketFactory(server.sslClient.socketFactory, server.sslClient.trustManager) // allows us to trust the self-signed cert + .sslSocketFactory(server.socketFactory, server.trustManager) // allows us to trust the self-signed cert .build(); try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, server.uri())) { diff --git a/src/test/java/com/launchdarkly/client/TestHttpUtil.java b/src/test/java/com/launchdarkly/client/TestHttpUtil.java index 0085ab0c0..79fd8f30a 100644 --- a/src/test/java/com/launchdarkly/client/TestHttpUtil.java +++ b/src/test/java/com/launchdarkly/client/TestHttpUtil.java @@ -5,14 +5,19 @@ import java.io.Closeable; import java.io.IOException; +import java.math.BigInteger; import java.net.InetAddress; import java.net.URI; import java.security.GeneralSecurityException; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.internal.tls.HeldCertificate; -import okhttp3.mockwebserver.internal.tls.SslClient; +import okhttp3.tls.HandshakeCertificates; +import okhttp3.tls.HeldCertificate; +import okhttp3.tls.internal.TlsUtil; class TestHttpUtil { static MockWebServer makeStartedServer(MockResponse... responses) throws IOException { @@ -56,25 +61,25 @@ static MockResponse eventStreamResponse(String data) { static class ServerWithCert implements Closeable { final MockWebServer server; final HeldCertificate cert; - final SslClient sslClient; + final SSLSocketFactory socketFactory; + final X509TrustManager trustManager; public ServerWithCert() throws IOException, GeneralSecurityException { String hostname = InetAddress.getByName("localhost").getCanonicalHostName(); cert = new HeldCertificate.Builder() - .serialNumber("1") - .ca(1) + .serialNumber(BigInteger.ONE) + .certificateAuthority(1) .commonName(hostname) - .subjectAlternativeName(hostname) + .addSubjectAlternativeName(hostname) .build(); - - sslClient = new SslClient.Builder() - .certificateChain(cert.keyPair, cert.certificate) - .addTrustedCertificate(cert.certificate) - .build(); + + HandshakeCertificates hc = TlsUtil.localhost(); + socketFactory = hc.sslSocketFactory(); + trustManager = hc.trustManager(); server = new MockWebServer(); - server.useHttps(sslClient.socketFactory, false); + server.useHttps(socketFactory, false); } public URI uri() { From f687e191310b13e8a95eaa03c113d5c14ecebe74 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Apr 2020 14:54:51 -0700 Subject: [PATCH 286/327] update build to use Gradle 6 --- build.gradle | 56 +++++++++++++---------- gradle.properties | 5 +- gradle/wrapper/gradle-wrapper.jar | Bin 54712 -> 56177 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- 4 files changed, 38 insertions(+), 25 deletions(-) diff --git a/build.gradle b/build.gradle index a598ec767..452a3af42 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ plugins { id "java-library" id "checkstyle" id "signing" - id "com.github.johnrengelman.shadow" version "4.0.4" + id "com.github.johnrengelman.shadow" version "5.2.0" id "maven-publish" id "de.marcphilipp.nexus-publish" version "0.3.0" id "io.codearte.nexus-staging" version "0.21.2" @@ -38,13 +38,13 @@ configurations.all { allprojects { group = 'com.launchdarkly' version = "${version}" + archivesBaseName = 'launchdarkly-java-server-sdk' sourceCompatibility = 1.7 targetCompatibility = 1.7 } ext { sdkBasePackage = "com.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. @@ -53,23 +53,34 @@ ext { ext.libraries = [:] +ext.versions = [ + "commonsCodec": "1.10", + "gson": "2.7", + "guava": "19.0", + "jodaTime": "2.9.3", + "okhttpEventsource": "1.11.0", + "slf4j": "1.7.21", + "snakeyaml": "1.19", + "jedis": "2.9.0" +] + // 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", - "joda-time:joda-time:2.9.3", - "com.launchdarkly:okhttp-eventsource:1.10.2", - "org.yaml:snakeyaml:1.19", - "redis.clients:jedis:2.9.0" + "commons-codec:commons-codec:${versions.commonsCodec}", + "com.google.guava:guava:${versions.guava}", + "joda-time:joda-time:${versions.jodaTime}", + "com.launchdarkly:okhttp-eventsource:${versions.okhttpEventsource}", + "org.yaml:snakeyaml:${versions.snakeyaml}", + "redis.clients:jedis:${versions.jedis}" ] // 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" + "com.google.code.gson:gson:${versions.gson}", + "org.slf4j:slf4j-api:${versions.slf4j}" ] // Add dependencies to "libraries.test" that are used only in unit tests. @@ -84,8 +95,7 @@ libraries.test = [ dependencies { implementation libraries.internal - compileClasspath libraries.external - runtime libraries.internal, libraries.external + api libraries.external testImplementation libraries.test, libraries.internal, libraries.external // Unlike what the name might suggest, the "shadow" configuration specifies dependencies that @@ -93,8 +103,11 @@ dependencies { shadow libraries.external } -task wrapper(type: Wrapper) { - gradleVersion = '4.10.2' +configurations { + // We need to define "internal" as a custom configuration that contains the same things as + // "implementation", because "implementation" has special behavior in Gradle that prevents us + // from referencing it the way we do in shadeDependencies(). + internal.extendsFrom implementation } checkstyle { @@ -102,7 +115,6 @@ checkstyle { } jar { - 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' @@ -118,8 +130,6 @@ jar { // 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 = '' @@ -141,12 +151,11 @@ shadowJar { // 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] + configurations = [project.configurations.runtimeClasspath] 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 @@ -227,12 +236,15 @@ def getPackagesInDependencyJar(jarFile) { def shadeDependencies(jarTask) { def excludePackages = getAllSdkPackages() + configurations.shadow.collectMany { getPackagesInDependencyJar(it)} + System.err.println("*** exclude: " + excludePackages) def topLevelPackages = - configurations.runtime.collectMany { + configurations.internal.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } }. unique() + System.err.println("*** topLevel: " + topLevelPackages) topLevelPackages.forEach { top -> + System.err.println("*** will relocate: " + top) jarTask.relocate(top, "com.launchdarkly.shaded." + top) { excludePackages.forEach { exclude(it + ".*") } } @@ -325,7 +337,6 @@ test { idea { module { downloadJavadoc = true - downloadSources = true } } @@ -367,7 +378,6 @@ publishing { shadow(MavenPublication) { publication -> project.shadow.component(publication) - artifactId = sdkBaseName artifact jar artifact sourcesJar artifact javadocJar @@ -422,7 +432,7 @@ tasks.withType(Sign) { // dependencies of the SDK, so they can be put on the classpath as needed during tests. task exportDependencies(type: Copy, dependsOn: compileJava) { into "packaging-test/temp/dependencies-all" - from configurations.runtime.resolvedConfiguration.resolvedArtifacts.collect { it.file } + from configurations.runtimeClasspath.resolvedConfiguration.resolvedArtifacts.collect { it.file } } gitPublish { diff --git a/gradle.properties b/gradle.properties index 58b55d1e8..a6a4f6166 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,8 @@ -version=4.12.1 +version=4.12.2-SNAPSHOT # 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= + +# See https://github.com/gradle/gradle/issues/11308 regarding the following property +systemProp.org.gradle.internal.publish.checksums.insecure=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ed88a042a287c140a32e1639edfc91b2a233da8c..29953ea141f55e3b8fc691d31b5ca8816d89fa87 100644 GIT binary patch delta 50050 zcmY(JQ*__$6YbNmv28nPY}>Z&#>Tg?ZQFLz*tTsaO@6W3ocFAAbN+X8xu3OW%{+TP zyQ3Nc)CQ5j1>1)`Atee12BwuLtPYEJjtg=DxqJglz)NiNfFt8ppyz}F17n6u7S_Q{ zR{BN&=y@(lqWr^}(lizSJ_^e%Y${FS!Nggbk`_|-iKoGwv^_16LnmbZc5*$ zZ~mjl|FD(W-@%ALSMlP)?t}iF`5iJhwPqhb=k&NnldYNU!Jm!q-Uu1+1=|M;_)ZqW zCUPyQJDL;oM&jel8R6>169HvD>>~J`mXK8_ty0! zBC4S^mPKqHk;g2JCcSQS2qCxbxy4rHnP;itjghak`ov{Z{UIj=^(AEB<~TIeEmxL+ zNo6%I!bHx%UW!5#{x3nh)-=DEQEuP}0M9$OA)_Ug))PCtWB4=<&($Ts$|~`k9Z|jg z#N)ogQjDjfkjqq9RIj?9-0tGHvMOK9aO7U4F2Z(i(`tIG*>Pfg6VI`*E+3;lpL1B5 z#vzWBru)dtB|Tt?%V{mfs;Imiqtoc(x57((Q7pL9p-V|2$OfB!{; zL~3`aH4xr0H2+uI&*KemCCW?V4pp#B)>pp7t$7;i3r=vLAl)UF>^J@IHipFy^#Q9_ z{Q>OPB(N^;SYPj0YDp$^`m~sO0K+fVu|kPR%CG{bP4n<-#GE^mmEK3tHp4@`Jg(7u z+3-LGTW|RRSHRz%vYAoNRj3hYcF!S*9zxfetVM1NAi36&s{SO zgQkG3?grt%3xv^YMnxMFqnER*3`>k@Hci(yRq<=Y2W0JR?Ngl09EMtRfY<}Qv_I(x{3s+s}-(%5dbh4;N<9;QB0@UAm&8)8W6yIQDoP z4k$bol?tJT(~H{O7Q5pifRiC6TX*8s6VKibLoplWN*TNek?$tREAkhXd~{6KuQt+J z#QCRjhg{~xa9=L}fsuS<`DuRL@N-5PB}t{PalCcrmW^LMEzkr99@oVh&WJx^a0Dv# zSVT45GHyuuGR;F18#(MV`jCw@-w9P$xeP=5qJ&S0j_G(;=XpqX0I?rr8jUPup(%8n z3a{kx>WW<6=SRrI8;0@}=WKu{eIPms{QsJ>Uwde}NzQj)qa7dBD9SS0P?s`s=nf5w~P z?HGDL9xQ}e1T1J{SD)%7G%W^UQ`1O}j6A~pUm}SwS<<8JYbaSx-OA@qHnxdnS@h`CC;n(>--wp*y2v#9BtEkZH z4|{^26%H5s5dSN5&BeSoSrJAZFsN!;uDAX{1!-m~DxYkNzEn;tZ#?4>ju4z;WPX_3 zyDo_i{1TetZe_AGNEmk9Oak}l8rGq;Z6UpEuJ(grIas>;58*ZZQ`l`dfZ=@AG%#dP z2i{B!gZ`V)4R%ZO-bwl9qAkL0eqWak`b+}@Rs-IS6|4WV6b5w%_n>DLzRQNL0A&O&am-c9X0ISZbN?`s&iDNc#r|JG-)!RfYIQGss6ik5>Ay^`iqt7I~x!hDKoKpBc6pEs1e2Uw= z4mlX*CZP&`6gJLTZGU!J6nsh?e12c-w8Rpg_&Orr$l|ZS7I59Z?tR?e{#Eij>0<7N zG)~q>F#cW-Js%4&Yc;)GRlP;o28eO=RcZoL!*<_3IRnP}Z=q58Z=vo!UW38@p#UXW z^>kT6nlt&3PB8zL`_~Zs1b*>;>VS!SdV>Lhed<$t$}>ipuUBCIp!d{g_Nq>A!C!qp z#lXHI0M(hRRP(1fs$R_gzI`La&+JG5*_iQPG5DntqcLzO1o6uJ>517J`vtoXfB-7V zA>1Q}mWA=@E4-#%a&o^T`$ zyhqMsHxU~XCCM0&zB|Kj6Fany%aOhdB3p7DL0P-KLmwZ{l4Qtk1n0Lr6bXtV?owoV zkJlZZ({ZIaE*4TE8opn>{=OeX5pH?wdPvKzlK`ku3QeucWL}HqZ{MIWm!z-i77BITrE91rp63XJaka? zs3f;>)r(Yr-HT76GfY&wa-YPO^W*I_j&lUL#Fu?{sNBXsVbrfS~;Nusk~-h=b5;l?HE*;&RurWHrJ zwn$o@V-1ZSELnBPbQ;abkGBtkFEc~)fg+HBvQg!pUrV?#NQiJy{vlsAhe z6GR(!2Lc_|2k2g#N&ZdwUa4}~9=S`i$|f!>^zfu5PBM+$1zC!gDI?v2<_EZbi-XZ# zkzvY*Ty|4zHw+g^KjnOmfdX1SLs0m7Df-(~jA7eW6SSRs2`~)a%Jzf5D9bJ8lSp}N zB8y{wVuoc>4tY%^p{~SCqRJIjc}j9vZQN~Vgr0$d3Pj)NY$h_4ffTth&zC2+=WFB_ zYFy0&2&wqzFb?^UXlJqRI|Pi79&zsQ36r02w`>29kU?-R#pP;I zWt7|auHVsY^;jn{;L7h2$QfgJMwkvHiD!!z7dKG%@}frFWZ&5iX|E_4{NUzvS?gN- zr1j;J216<}eD%gTfz`!2=g19P8QIx|9=2a%Dfy{eY4YQ5_Qx1Mjnem2lE38?N5;tO zXofbJjh9d&Pm!~`el7UVAT+d3@kO1x=v~PgCz8}y&b8+dN4Mt0jfU$!ujkz;#PLCz z@(LSCz>j(VN=L2D^(^C)SJT(H z6*CAjjw6g&TE>VsQ9@*YdyXn|h&88QrPf=c9(uY;-g@jqgfft08t;;3MW#a4lCANW zWvI_Vp65JG0$8K@Aa7N*gb>xRTkv{)Ct~Q$z0Ki|Jt>zcLFNS95Ru}#TiApi# zyIZOlV3%n6&x+Av)v|^C?h#*6`@LLOSSIn$K)1lw1T&*Dfj25z?Q#}7V{RqYPU7!` z2S*JfT%^59wo599>(L=E8Jir^%7XdXG@`n8{4-t7gq*6DY1StPj=tEPzLTXp!dq(d`7KX4g~?L@^mbU9WR6O#@_TiLZUu=* zomES|LnZ#3^?X}yu5DS)oc1j8D4L9h$O3iZfQnRM!oa^k@x`e}AK3Vfyjp#WCQphJ znh;Hy*11-xQ8xvye`%%`f8@`Q+Z8VM%N1_4U4XRerje$}veS0;R;ybRjX|F`JLQW? z6^Tr@q3bMfJ*vZKV`}zv20h)S-buBMqt!1V(Gwh+{Np^RF4=1y9xJ5-T2~^xsop)j z8=TmDxt!9gTUwK`WJ-CxOPeBuRl$o&qu&g)rG-9uoYTbQ-p6y@yH|Xt52;bTnb=VP zH*gTQvCDudt6dcWr$bjWfbyHvX@ATmXF%uK3~chpsB^b*j4Q3;h zPiSerzz2J3o=YhxzfVn0c3HItjAI`E>7lHK7zZ4x4WU6pu`m?JF}}z82RaV&1%Z(& z`4^04mzl!_hGXs~v-)FWu>Pj*vM+|#PI+aLvw1E;L54?1a zMW@i<5Uin9R}Co)C%S2PJ$;d!){=y}9FFwO%u^phWvRT1z3|u*WM(TaJptqS8b8Wl z(Z8|{SxV(?QKMHC3T7tRU(i7iT*_R+qtRsz9n{B4W-{6Jg)GO}gQ{76I@zfAP-3Ly zu_vTPc=VfeSgS`5mf4k(?e?(hKY`k+ZC%2u@0m6=1na6g&lIC|W*T_aLO~<>65JhB zv3iciH^NRk7^{8VB|gq4l)iRPNpyc}byZuxx5sJ(?8CBi`XQYRyt8u>tR>H`(#^g) zl2r)CMnj~hsSCwJ3uTE}z8y}ozS97ND4Sy=Q#N1U-~5d?x;9pGi_ z@ofcK_D{~-=6k}{Y?{_UjDKf39?Nkx`*-vm$wxf002l~!YKR*{ULWk-Cj71W**?Pa zVZ%pLVzTvkGwyzdYx7Y;^9Vvy8uukyfsSeA-=bCJ&X9A99DSmf%t`MY2>aFCf%Qie zy@6`4RDa4bAYM7ocKc-x)Bq7Wr#|h5){i|U8QVL!-RPHv!t6VhPwwDVk}7fHw`=t;+a{{Rm_-*#hpn*Krc_>ig9J{Nl zAl-ucp2!C~<^E0zpiSQ7YQ=B%{V9wM3a0^DNGW-?;T1|%Fj&b_$B!4j zmJc7V+{shvq54h$8|W@i>|V6)0+)lip6*-S4rvwk1KhJ>`n^uslV; zvT$Nidijt@J+U&418LG?$> zYSPKDYh!(=rteSzY?`(O4#Kqx&~L0^-t?Gu|&tAuwNxZ_znHZ8M;uqn` zF9{(wH#2#_unUf7LlzvMJFuzU3XBof+4b$%XQ0URE!2Mga?U zECj?berymGRLhftS^kg^X#eYZfjbLE0flzWT+b&3@j1>42_lrt^R#_Uh9lSJ@Go@{QQSe8uja5a`e&8J>8~@N9_T4(RVMgG$7f%n$vG$Nk zDM8;^&)$!hkKUcW&s$Fb?C$%n;y!7P%|I}Bbs95Z&7CfcamK-)D@t?0xhVVGZShNw@G7tu}o^cMFn8~0c^hu;mFb_R3$pA!}ecL!@O zxEJdnp>alXWK;An2+^B2;W0CH3bPEp#)tzgEtu7tIvf4B@6R)<^)K(_%g@sWU&Vm7 zk93DzT~{f0-JLCqr7JQVIOO9SU4x`hmtqFr+dohJ!{T40sUh8tSf``YE+nJS-}j#x z2epPU5$9}P+k}JMQ!Mt#8Y0TOIgT;{R*DlF10%2BZ3*7BpAZCOTuZsw7V}B;#~8LW z3v6Xeb!KNMT}^b6HhxM7s z`yDx<&?r1_Kv5bt2wcQjZ>+Mac+-RD791vQyUKu_V2ukdkKNxUMIlwHcpU)Cavpd> zfD$?a|I6yuje+-G8K*W5zwJpq4lCa6O(--pRnr-#<)knMsc855Uj>2>T#a-J!TAFv zHgb=*wNro|s-wexsxW2M9r$z-$2z#n>Br?nWrw21YdD4tU zQP-s1UTlQwaXXepciq}*4PdN0YrS&(xm`AkZL;T<(DW8yx$1lrQ68Ceftmu^AHJ0z2W*pFg617W`!-Sh3YQQC zXT0KbZOAAW8~?s#?E=Ul#7M(M`dyuWB;lWp(Vy?-pnr(6@JA_y7@-8>qF$#Iet*dy zE+3a*OWY$5;}qs^i#T8I#WRhLP%;P~b#O&w%!Os*XfX^I+@0F8XUDBAyul;VoKvVT z&=A3Env;q}*y|#o|L56MaW_hc6I{r|_0nu+?FoGC2(!qP5?C_yi4 zvHVj6;!q?cJ~73-a)_IyXzQ5ekksyRXuYk!MobX%j~vI)+6pAZn7HFY#1Y3hVk9|Y zzI_cO3d#+=qIF4bB_JcX5l~45q+X>`usGl@8^q%>s4||iE&`x!l<|sWw73$nN0|Yv%Q0g_27QZkQUOoA$H+9tL>P@D2#*TTU`8t-dul}fTUK>hgH993 zZ!E{wTzup@TjL3Q{RN@-BVb`X9!AEL!%_X9C9NX~35hAu7K$!_nWL1ajrguCYi=+K zpIA;gEuCS3qtB^{1$Zv;g|18=x@^*1TgJ+pIM@vt&o9E9HdbzxsjW8uw|P!p5XiZl zLvN;6+Y&b>uvcUG#nzIp)cu!0u4M*C!vMUa{pscWUH6IhKX5T${zS9yjGdJ41=J2a ze$5^&V?BhfbEkvhdh9nj7doap)?uIi)sqR*U4}Z$zLpcGz&rI^je19UJ@nKL-OK7W zd7j74kZx#=XL|NKLJvsXUwmpsiy<4mjQ%QnWC*btZCu)Gf@BW+CQOC$YPFjTTRTjf zJ~L%sZ6W?!QTzu&LYa!W5?hXCS@oSg{2n)8vK&Gj32MhZl&l7~oZ}0cX zLjqCO+UDAFwg!mw2-7I36B@PL@symV(e#R7Sq!m^%fO77(1>q^t3;W(os89oP*PM< zpW-17Z~ZG!8q9#slMQ!-X8k` z`>!u?|3AVRVCTJg6!?qZ?P*cZllg+ePrtsS;t1h7>b%z`FLO%P?PMPm*&sNO8y<7@A zf_)=E`}U9j?ben582TLlBh9Z?)99$$4!IHCZbhd0^r;=VKPoD81EpTUL0mo{D&}1V zPe(unq}e?a-R=uQg1S{_(KDSH?K(c_(%6oEq3KYPh=mBKk8<$JKYk17^b=+xR(BSf zrSlRV2sZSS4ic;}g%vC!DK+JxQfC+~35u`MrtRF0566)A1~o+S@)sSdUFPh1y4|5<1LSc1Km9MgL{f{gL0yy=pzA898h#v{+bz-?+b#L z`IN)#8>~OM$H(j&xmEup0+jEhFd<%(g>RmFZgLCj#6I4;o{2UW)|T8W9AAVz0i*g~ z%x>yJg)qfF_7v-mBjk;VoPm=_Y-tUT85|W-$zj3;5#7yp1wDMrE14&1xsn^r^<8!( z@CG?BjAV3MXps^%wcwObh{51_NUj-+mf>V9#auid>k-K3;l*LA_7E-AMEpH zv65)u)nb-@6x?WQ;l+}7=K93M0N3Hb&9g^e^VI@53*HItk(zP9-vQHPhYKISkk2ZE zIH+azvs@qjg2{o1i~)zlih-pnP{)KAy(tBSxNu~4*$OV{&y5ZuB^*DU#A+*UJXntm zGczB1ZM?3nQPSf323$T7$s}VjQjxEV5Q~_b*{DgA`9xB3s2YJVMxDM1#kD-cl<7_grY_pkI|?-kiO4nBoQNfP0=)6SPl zIo`kMa(4b$D-8bX#C+pZ1+5(<0hY<_`C$f?BG`DEm4tp=#Bz{6BGw;u@k}a=15EAn z!``18-Nh=JrBfzAxi>2o$CC*s+}UwS81@mTk4nl}7o%9CUc2NLI;>etBwo5#l|CKh zrG7%a>5-iF_W0|MF$c3yD!NSX&I=J&!pXFYUD^#zTGKYF(}>QjJC>rNtg1-7?NLKl z#34c>TIEDJveU@7q`EW#3o7hDGA!#sW-L0>L@KOk$|!B1D$;_iAmSuTo0O?=iAlNR z@x`t8T4A(iJR?yc#kR&A*UmI`dCfJ%($=)K0)2h6aLlv}6NmFWj)h8;gkS|d(%Fa8 zX*dRE@e%c_71oBdo@>2JHvE}woh1}kz$dFw+GB(RP>F`set0DVH z(cPK?P)Qj>cwT(C9;<|g3c6}tGDfyI&eqDq@ z)_Tkq#sb~dktOdz_l&q|yM@aS!tEH|Hvv|qiNFP*!+SB4t4c@pbi%*Uj73w~=u}l# zXYWUHbkDSWwom8PtMfN3|4jTNaKc*VnQl{crIs?id1Fs zc!6-6xr48_SB`4`TH-_y@#ZoPif9(I3E`NYb( zjp>QW5#7M$;|t`gm*URBX5Sb$h_M|8wpG5-Ibv@t9ixFa!!rP^KEO^uBrRkC8l!?c4sbEX?^R%;}l@ z8<^uy`2yqmtWkn;-yC-39s>D>;g`m#D9mqMOmL`c36Xy2Q=xom31!8Gmb*HFQcB)=6MW@P?#ZtU!rVk z)^&R=(V}%V`Zl>kN5uZ!(=r@f>hVTepnsP)c}v*i3&ce4Fo&a`u+f04F2C7@C0^5T zd$85jmGa~$z>3ulqN*9gyB3BAp66|}j#%F<2@ynO8sJ#=v_{ZlaRZ^5^{kg+v8lU@zaZDfIo<6OT+sZDC5|KEl>m)sq)z-Tg2(LyZK3P^}nc?BC=L7w|crbj>n< zaIM@{i+A3x8L-Jz?{$SJmK@k3=o4~{JMYT zq!sWW`jP^vG#6TTMdiwM`>SAPGsre$D2)*yM#@PNM38&Qd}lQPa#r7*^x(n!X=dn% z%%L(vsL=Zd!{fPT!uUD~$Iiza!&Y#MZi@c+C1=Yb|EUS)F_-+s!n*jy0+RQd+KH0B zIQ*9HrrhjWmlxlUzFa5u8mV=<$zpmbb^3{lS8yD~-cq)OrQDM>+mj`^6atU7FjB^i zkB}T+o6j&P9jzCLnNMdKf?}aRPvKjvvkp3=U8eJTThgxE$AmL{t3jarwk<;AwbJ~E ze7seS3xbRuh1l1jKj4-g!U#MxY&^hm>KhvkKZO3*!@VEkoF=m{ ziU0#wb#w@jA`8Adlj}4$6-#gSM1Ekzve)XCBos#XXLAqk0GM{cZQ&Iu$!la?$}&Hx zd|7sm5szAtdAI>a0PT`}t_$T7q`B{K-lmdJSj4-eYEsqH$qM0ym$^yFnpCtM zW08SyV^AoG9Ah(OpHxk2N=a))X*+DHH=$)`Y&>M22M+}=6^6G6H+sY7hSA;kWlLh1 zijukG>2=)oec5^HdJz!#xTVg6Y(NDJL|x>QWMmj*$|xEsolSxDkf128F_h5iA{;RU zC?3$U7H!m#3qPrrqO6N-lJ9@K8^ODqBdq=4MrE5`YnN1O7k8xVIMH*=iFlV>>yP6I zgjc<}SAI@!A0}}>j2?)TpJpPw_%%XbkZ?!})QBU{9C5=ymI7Vi z{08B6f^=waTmcr|J8w)QWsn4>5t_A7ot{Bj@)Y)X>9xh4T}< z&!wkY2$l`hX6d7&9#z4>Hwzrpf=jWZp|$FahBNCBAN2}}2d^aA{On2&JUT z{AB5ZtOHuoc4B;af_8FbnRt5sS&wQRxI%)+qn008W^SK z?)OcK$}5ODpz#)py%k9v2)G!?VtQNFXcd#SbZ>d%s2fZJY2H!P67MMmVAdNHxh59!Ybr6S-5rCMI-{6lv78Ap6K2ed`>|TGYdXX23 z;BJZXnVEEj*Oq>1bzZtir8y#*o0QEG6k(GZ@+8Y?YFeM$m_wGxYVPGtU40l6R)2(# z=W)P+$7vQFR-4p~x5fTw(iKLyUTMPBE2HyN#qI}RkKWOS8rYsDPjANpR;Rh3s z27vXaV4?ZXB-If{4+AYz|9zufuT)|O=t@|6x# zYw(vIz`PcQ5g*uK3a#Fu^A{id=jg)nk8~`BhnVjOmI@BiRcGseh~yyPq#ekR4wFM% zb(GWmS@Sad56J}t@3DI084lmF|L6Be005mIP5Q6m#H7b&+?r0)Eem`uq-yz@6G{*G z0;(7@uLm*Im&)&F>Q9{^qY~I)R_$smdYZ|f(>?u{63IpiGNq0URHq$vr6Rg6|%AMO`&zf@BnO#COUYZ5y(0S1e$ zn%6w>3S(Lr@JboVGMYAY&ES}}9_&ixm_l{i;gSqfjk*;O`JNqZ z&93*+T}WE;phkWZh1Mf}{Vg0h8QNH8TzzKuv2W%lclTXcR95SvLE`z(fY2)M9&Y;_ zb9cHnA$7zK3A}k%O6d9b)^wdc5s77^UiY~`o=**j4F-qK2Uhz^n1K@vz(HdGtJ3)6 z>o44c#6ltIaJ8m2n~)er%9XPF zu{3FY)e%(^Q+oE+bZA=c;QZb23Q<$4W->faz zZHt#2eW|0ev{XKv%34R5zGwxCg0qv#K@hOemnvh`gwC!mdF^lkzEz7w&FFLu+(f;OA1NEkifD*3CDvXuzxXv037C$` zSer%tKhI7#CneMvRtc{Xu~bYPV@ZkXHc<~x z@gxQ2F_GuUBpR=Pv7>`#lp>>;2$p{#YJT$T+81BK3MkoI5i{(l-XYf0Xp!EgAoj#p zlt!;OX}>USzdW2be5KxyZ;g1qBf8sENgW1{M@TF|e^YX>j&RVBO||)>{12UDBFb6l z@`k>Fz>(h9`6h|W%4x5VOpOXl+?Rico`3RrhX089LAf0Y_z!z)A2tLngm^)ECMoSl za7UuXpw;WB^ncqVEK>OD$2p;2ZbLXahCh13%z^O@uekc5^;7hbvP5HhrA@CJL8*fK zo;9u7D>#blANt=h_3jHJgn=D%4KHoSy`jU5?@oAuY!3LdOsYBuQ7arQCAj%Q z@I%D$WM{W0G$x0wpHYc+p#1z!=MM5K`Dyw`$iItZFRqUJiJy1ZLJ+|*ol|DYjGKIak z6qkHv-JU(bCN6Ex*>LggWB?&knW%l+Cs!>R>OiF*?Hp&8L#&$_euRAuip|aCI zEG6Zacvf2R$_g*{^@g5X_DV~ayt8K}o^mxWQ_t$eBR(r_^ovQ4Kl3)EJ3>Ex5Vx5u z$=r*d@(vuuBr>c3$uyZIrc*hWPUl}AsFl>CxnNJ}wx)-H(2$vd)PKwNhNB#YhO_kD zIJL`Osp*yw*sF@kWtv$l;3l!cuW{i@>6vWohSi`V-bDNomY(f#1w1Z0 zf`kKJW{rCvX2!#UQBr0`tn+B@>w^ft7HKx4qvme)A(dD&LtWCIlTr=8nDC=T#{|pX zbS{_b@GXA7HWXh#ku!g44>PUz?BJn9$cW~xcNE)eA_QmQvw+y~5We)Sa5TH8B~DAu zHPQYvaTMF`(~Mcd+%W?ng9)J1|2I7)bS8KFfQI{56vHKQG@TVS*$@{gd*QwSH9C`p zYA+}%)PlY$t=>Rcr`b>EK3%=uK)1~gRZVe)*DM%NL1Q0OpybeGNBNmGwcN*# z7N;Y*u4@B%R2I%`<^n^mpkSF|W3EB5slJH8ffr;sF8Itdl&KCo!2J!3(_qK=I*e#A*uTc*>SEpWpvB5FMUF_dq@oenphJkflDTjKjU@q* z=tJJI7y*6E$8yn<1u?%}5tAXoH9%7FvMQx8#EK1RinWvzLr~M?OVh$Kj}ecjNrIM_ z?MN?xLW`OiL3g>dmU)kikHKvV_HJFk#I#3WBvJ-^<9uzfNv+Y=jAZP*(6h~owPCYb z85WZd4jOz9YLSR#9&^*UwJF05Pq}{;EdQm0J`E4PfT6O(e+Bn;DID!5mezpYED<-t z-V1I!VCv8R9aM(O&s0yH@+rT2WX-9U$^C0j*1Ab0a1L&OQgUm{%+jA9uVGUbYY59E z0v;Stu$)dxjxBoJ#)zAAlG_H?U;1v6N($|1(A}saFJL$0=cQ+W-{PnZt;U(KiSYyR zR&2%3T^7T@4EN~@8=rA<7KIsQi=$Wd$jH_WridGys-|wDWpWUae#W|>kfxU!$F%!-CGz5QVYDa4$N8O`nBn>a6PJ5bfJ5(%S+i=WDxJ_0Q5 zqIDcI5p74%TNIGD-`KJhc-s}p@d%NvbeHtpW(#mfMKz7PCF3EC6bfkVwLujdTBtzJ zz%wd>Jh`PVW^IFzoO$PROc;1$#yWp@9al?g5Jws-_4JmdIVS^KEHt0rXib>(w(LfY zXwMHcMBKDePVV6Ep1+n9U)=C#5AP?U_!t-InjPk)BKNB#U!{r!c-Y&LNXe-cD zO$uU0^+>)R84dX?8KOeK%KQGR5WxbWF+T`Y$0DWK55lSp*NduxP?7}h`G*N3YT-f> zmw$L1pZv#<617`N8=CN^3up(pvP3-AD@G3KHhw|)d0%D^*e-`mAR8vL*!H8c`@~OR zBIgtSVjs11Yj(Tom~OyykfW7`C6P2TLas7T)r=zYJlLeC+IJ@=A<#hjuXwwTv(n7ovJnISC;oTeB za_>#Q6)Tz053=^_o8{{;I78a4d|(O?&Qi=J#z3lD^ycb`lwa`o(&4v<61Ai&V$1bT zG9Ax1RDGpZh)}ra5}yV#ga{fxD=ZnwQJ(P+IPIg!sHG@66rQyx&|3DQUhz@a{#w$z zcsI-lakSPuuvR7h{FJ*~HPJm2=+=UKPMGaG$(1iBPKkj0gQG0|S?DMgIE(y1&TAkw ze_Q;SG5bM+`BM{0r@(N;3@BO1j|`K9fV1WH5%m)7z~anuRvHJaAGq-G<$+wAu)k@j zwuvQw_HY`qsFl7~zYF5s>&=9Pjy@_)YWFDJw~Lc=TerTEUT_Xr=;=BSE>cmi4zwBB z8UrNI2jZbpG-V=judy`Ja^h_35u)kbe;i{ZMFWN$C5~7U(+#W?utq;GI4X%twJHHo zdr@v&tiP({G@)ek*)ls9ahQMdS9*8H zq?rMaW0S(;@{^#a^5ORNh4)KDa!$UgypuLGX5W|{BzAXnuUKYL(|D&G~( z(%-1e{Su43`spVe1g?JMevxjC`nl4k#gGb!iUxs#?xp$H3-qy{X!Xf-3f}d8)2jn! z6uOt`PN1sM`ioCqp%DIVc56Hq7P7wWjFE!^l@7H672I`6a{I{Ykw&Q)`U50-x`Pv4 z3QnFFRY38K>s29kZ}Bbvvu*b4(6ga!q3+!+AboG?&Ho>mg!L$|RJqct*fK9KKvDI@ z^+jEoAo!FqODq=nX~Ytv(&OG%B2~!AFR)w29Qj)i^~S%dxWRl`lNT6WyZq#Fl-lR_ z>X!m#=b!?TrCc}Tj>|F-%Chom|5Y7pW%vq!4~VP4>RS-m?|7@n1aDoINp=dg$o1gg zCo>DTd;RWceB zW_J?qAgv60ySET7ap_jJEQIKrDn4MTx$0SqZ#tZ>Y?`uC72uf?Gxg1-PE%iY@_n9Y zdAYSR+k9)JlaLrus=v2tcbNai*U(i4wDJY}S07XDCGTC?P|4zp8~Zm7E9NDLch$07 zvaLwl#(YF%iju24{mI)2+iRGxU`H9%^b4(Q9mE8_-MW!Tl=d+&KymC~9}eJ_k;eT8 znJ2qaH}I6?qM~N|GCs&X9knzfy`W7+Mq+o;w>m$Yj0>a&&r8 zJ+zUw6gb4zxcqz8LEe-pSTBthf<l7P$neRvksG^QmC?oaa!XKVDMjz^fk=36kqB+FNT{vh1BvYX*9S)vDC*T`u}is zj=_~h-PR5}?%1|%+qP}<#K}p=)`>bDyJL6Qv2EM7lg`a|Z+$=Bw`y1IAN$|hyVlxs zj4_|NQa<@yxj!b4Z8rszU6>iSyel(b37bg@C70pQRl8zGDdcxllR|8VlKug8_`T{+ zu5-Y~x9Ova5{m34qA7Kl1`h227BV$AM~M#36C1l?e=@hEgvosOsFU{R(Sj@0c!N?P z>ts@U)2S=HmSFYpF^u_@RU7O@!ZkpMgl8s!`8e-9Sm}20@E%`6*(339$SXF+B}rAD z4NNOSwAXad1$^ItjdUXZ1y9!f917nzj@VLG4)DULd-F<+WtJ1_dv!8Sv`n42&~gS& zv^Ii_58Mv|cCiVi+vM;-BU66DCY%Xi_i6B(E!ejZxojb8{)3dZB7B3>L4ROwJ1Be} z>v>&BTh-JR=Ae0QI>KgIu=98vCILFg07@^$mO}B{fjUNZjDrI$-_Z=^J%Y-90j~$G z;WrwiOwn}lzu&VM^XQN1T)5Euo$@Q{>{;CAYFt*MKuf!b)n#15{!E9!4xLsO z>u04~#>*nqrZ7T|`c>93UKVS|oe_ZClIKXJ>K{aXWU>F)y_{XzT zbzaF_uIFHR_4{qQUA%6NLBdq$kyIvIWSjq`!PNcLMNy%AL%_-f$D|MB!<{W;Y2zGg zg@2|(oN?1gpMLw}Tcc+tiJ2>qrjDUV)J7R*#d=z`5v#W<-Ra6)Ajs0aKi}8N#0LicnhpO>Szg44I*04FK2JDQDW0M8`cP z*pvm?x!-2B({?SYYBYehQNsM{^g>O>(+hu2D7^$DpRsROOW%cU* zJ~PTB#p0c71z9;N|D50{UPK6TL4OEQiy+B)Mv;#XtI2|O))fJ2(|tbzf2qrRBX!y9 zo#egO=hJpIOxkS=Cel)sm)aH8wY!hJZEV*t*kcA~$?lRqse=N3iZaLRFUmIT;0(zX zWi*796w+5QZL*J@WE_l!*>A}F+cj8fRQofvq2&H2(P-cOs$j3)W`PQOeK)nhsVYtt z>`zc%MShb#4RRT<6zqZ^RyjzPx=@nH;E3wrM!YQXab_>iizn+4&a#(0|ALy;4bGL; zXbcVS5iIkVlfGEn?@)9cc8YvtCXC8*vz^m7nzUdh7jk=!u1qjw%i6Y!u%m&1gdG3w z%_Mfnne^yU2~SHugS{q1NoRZ^+rzBNk2`gN&+Slrg7*NZ!sgMLR}w;ZZqtS`^X0eL zA~2p8Nj7C`=+bXddri|4YEZgbv-ay7mPfs(&8PW3&amXBHOxOkALwCOPjN`Vg~Z}yJfU{WOEC*L<_9Rl0I_&fjLZYWadQ3`UctJ zc8UN)qvcO9UeP~r@mg{GKE;3o>zH=;1#C9~v`xUP!YR~_&WYUiB*zooHNWwn=)XUg0xrSvO4s31V1MxnEF^Q$|Oe$Qu9KLw5^&wfwdx^Y+ zYh1kjkaYk%ZduPDANXV-US#f%7(oQXbLnCK@4P3tnct9Lqu7I-AUr=}Ho?&$w>^^G z33%9(HjAGyARE+uGZe4r1z{mSMs6^XrjLP;L?6&W79(2}XsxfZB-qw%jUa`Q*|=tC zZrN~~G(^;HlI}>mGlxIAU18yH?vl5tP<|H}!Q28{vZgI+Ma@HcFijrQx-|O!NLM74Dq?Bu@T-oj7*;!q~nWiul5`1t8Z7i70%cBnpXq+$Evi zvJIOHWJ`TYwE%YJbzU?6At)kCIK71mK}!wOQSQs1G)Q2zrwC~eG67OLrx2vyga5%W zNkD(IHJ%ZP`#XQ!4Nf<6T)~tnrHjl37Mko|D( z|BrQ5nRH`H<>muf^C~@Y;ztTAgpC{;IuAk^QbiIZ8pvNr3zO@6#wm+uxq>S&{g!dx zRpRaKy9$#Ry5MvllP7g#bvJENqn1jfz~u>(;|BbIjAZcECPdUSh0VDvjrg8}rdrW0 zO^{!!aj&cV^cYim|Mqwuz%K*|F9L9xyHopi{T+F`5G_I;{l{okbnSTQg~+~=>p^oX zMAi=h8gzoAX7W%_UkJLH5&Q{IhUZ>nyhVnm**erB-6;oy0-0n*G8V?IIR*zASrt{t zaJN-}qmZwGt?)O$^hf~p%DD>34X2bA-rtalNFgNR4HbJTF-Ipyc( zj@%|cy_im}goT0NHhH+xh=0slg4b9zRN@N5+c><6TIna6AvAm9gAK!vN0w2TAo)#n zysdP?-?B*6c*}r~$8+^>!K53k3^#THJQ$Ng!W<_St>*UY=dMGEexEG6M7ZSPW<#DH znqJ;p7^g51$PvA&{LR$}r6VX@j@hKzCj@s-2Ar+M6^2W!44t$;Mnz(!44z=!za##x z9&7Sqewn6bl5wq=s%5WO;@efy&Wa5yj|jEo_mu#4&@ zi*c=!Nj)n-sbsQ*n>P7egPag11y^m5f_7k#WS((z6-dx(CW8H#QQ!uWL(~rtCwH}F z2}UkUJckAjaZKc||HKOMUGYD}@M6CDXP^+cdg`uCGq;5T$(gxnxuW!{UeKsyn&gn_ z=?6Ol*>5l0Z8vZQ8<1}ZD{p~~O67-%TA3_IgzJ3d1Jc8~)>2CyjKbXap9nB!H+&19 zrta?|GZ2=PfRC^ajZsl4czBI<@ukZu0coO*`>6sI4)wb^bEog%e!`Kml1gkX3gw!o zMAgZX>a)j=rzGVQ$XUlW4(Z(CQY{eXJA3xZ$V%B>U?AM3z^j%QX+6<2$u6fPKhU~0 z#Un9CFjUXcy(eVd3?FQtMwhA_w$izJ(G;PoY*0D98*ij>ul)l4SCi29P+MWJdT=l> zEl4mhp8r$F6%~;{D)JP-mW7rFtqV#$;$5u`vH*1`RVdPBu^40_45|?nYD2F+i^As` z3`8;Fckq8SCqG$J2H|@mvU&epu|;$Op95e_(20jwg2TuJ7&4EmcsF03w+j`n@ykKE zWn88Ot7u1LTDtyuXsrv*7xaqPx9SVJFA6%oQTM*vO|XsLQ3beLZ!$J7F=9Y-9%5pO$PS<7<=-#{|yuEv?F0Jh*9uBv~FL`pV3HQ zCB>>9quOXzZ_Gg6S8Th3fymQB0t%IQ|l* zAY`&sB)B>5c@SwTt70frnJ4}q&M5unWjT}<2U<@ww!AtV{n(V5;qXh_v!b_TCfn6U z#U~GHN3z!ZxUMH|0Kb=2Vo)U23(M8U)8SvgNyE+Tmm}b&H+r}3dYp@na}RC?s=-hk zY;gx7KAR35e7#}Hz#QE{9sRwH#23-v&AECy)3)p|rf zvYK=`DcPQ=7m(2nTP z75m3&4Mj&|;lYn%?hOeFiVX8gO-BxbzqqmF!vlWTlH%pyHYEBkkK=Qo`c5}090dm- zK5r)ncT14UH<8bkSdIfzxV4b{M2#o#gPHf!J=Rj1x^P_<73(i^nU-5qU{c#G&jt!i zOPS6lUvWBD^GX!+3U5B9gFPv%dEWNw(3~yKSe;A$ER7qiEJ}KLTsx}5Y@)eDt*jc+ z!{cSz!j_SAl&8ny8baVs&Q?-4uZBTv)OM!QF}K_n(_5+bIJ^U^R{XF7xrRIRn(xx@ zXZa`xr$LwYL9^E`YvHPNC%opfT7KCl1AIe#Yv;Eo}!f04_s9?Y!xs zYC)}1uBF%l;#`%HFp}Pm!kUfYW63`%-boquYs)n293@V(ok5zkY%K^_gpQp@Yk^BTr(F zK4y~ss{9N{7O&MiDov4ejqXDj z2FI*~?3eKZsrZD4m~m24!i`7mFuG>1?UMpS(lN6Fo&Z2*E+f?&eI)jb1H&l=p}bu$ zyubuu7Ad@ck;1mm?9G@SZT(y}Oq@=KvuWo0p$ioSEYV%CgV=y&i|5)NHb3A7#Y(cyHLHqXDj4KQ2cLmXB6@Rv>h zeFP4j=~w|{@JBM6qr2>%BPdKToa@Ob?~cqXt21*Z`cCbeJHmeK&O6 z9~~#&!U8|}I|c*@hy`>ChrTy1e*e%QJH zF}dD7o$M92JT4IbuXm4SvDW(P>N5`IF%Yh?GhY?xa{I|aQKMe8fxLy+n#H?*C(f^@ z;~Ts*4{;-EYoG7NTEWkO9cxU8EtOxHfE8WZnO*JOW@?2N*X9>sZMwmqjU zIAR?)v0`sl5AOB4rAAkC`8d0G<6&-a@*aqZqn!TWe-b2E;~))Fb6&Gvm2B?dE!yFL zok0h*K0_gs=NSMNH@q|3OK}r*^U^$%Xe&m#f8-0EK@mTSj+$Q9F}nFjRy?_D@xY*K z($ut-WAjMyKnB2W8~KMOAJXm=;_NYe8w`L%R#*2RHzP^%CsYK`Ulc+AGl77@Ilt`I&#Fe; zg(dfekWD7)r3yD(V)7}nuihBM=Mkc zjw%*}`z5-za*l8hO9R7>_V;D(`w7=Uq$W?}C^NpEE^fRcQk|Oj07MO!D^Dw!x`Sg{ ztMbok`xgxLe?+SlLr$@KJbEYYFd*QD^8Q>uRv>ZSC+Ynzn~>DI)}ZzGI|0AUTL>&e zXDPdL>Dv8zV5}SLXnAykx_Rs1E1$C534p4wlpe>s?1>Tzv^H|M3BTEXv5B>V0TA>{ z(fvgw7e12jDD;1T>5fH*50N8~wOO7l@>T~Rui2Se{2jU(zI>P0WRcYww;!IX|g z^C_hA5Dr;b$@nq;Av@{Y=ZSbt>oRQUSTEt z-+!H>7jHz=+fZO&wIE{+exSNG#y>7u9#R4ck$D}BXsuqxI_wHc8GTGU8YnrM$j)VP z9xCZR-jk6i5$DIi8s+TC<%qw4bA?j&N6PP(B|@Gh!Xtc5TS8H!a5H>AE5G)yEhE1R z=c>-+uFsD>WiaI(J+O@om1{RJqzqUSG&?i1NM3sw18|^M;B^<|a+RVg{Kph?1gMCrvDUI%YzKw5nUhMK({c zE+Yf~R~rjEzo0lP7ZJbo!9L|hG}efX6%Bq`G zLglI+SH)UVzwhLXwv|H+!qBw4H7CgG7pX^)N9iJ+3yTKgQZ3n@lvKhaX1dSNutZ5C z!_x~AXlc3$^M?s0mzC9>(u_ogiAlDlpM$Rh<8N!iJ~rDs1K{G)iA;m~3+9GLId7&> zTM;#30bXn$L_6JN0&iOtZyI8tbTdJNKxXu5Ep(=QBtVMsYmuc$^j*(3Fb^5I3T9OX zo_rsLj)uw!;~kW5JtxW({Cj7T(_8x<3b*Tn5#`es%1s8URMhimO^XyJM%Dyu&UfH^ z_Cp}y=$)w_fM3=EGEGeFdigF(4vNhh-%(|n{2fDhc`wAtx@H2?4?*NFOy!LuCYKu> z!AX0ek|FH*8oWQ3Gv8pek?Fd+Uxm64c|+wIM8u%OT7A3rT-!=^U8Ch{+n?mMfTX%C zjiez=S}n~=Q^Rlf@zGv4Il)hw{y`P{h6549<jA$fZtrYvZ!4 z;qC3Eo3Uh*x;Gis586{VES)&Ny9hrasbu3pCD&Tycbq~bZywZ`xM4)dZ zl#u{t17WGorgsOohA)$+34K0Bhr~1+qwTTxxJUOds)MyT@F}Yfc3<&kjOGZ-3mLd8 zvzu6N7wSgVl$?58W*s6Pye%^E*xP<4gIO^SF6E9fAIQD&i`TV4?}Pt;R#y*Bfj)8D58H27+<5oy*EdN59nVjJYsKup#J^w>kE7`!_x$3nUwQ!(~}R8`HZw4 z#CM(w%6oc5@)+XIXL2eCJ0`rSf<7;bX@*#haLV6%Gy!ktnx;h|UhQ`JamLd(LiEGb z891%qz4`Uzt=hb{!@Dx5+SGV@Xhr#$0QS{jE>Mr#r8^`H|B36HQ?Iy?FRInHDFI-J zB;Finq9eY;pMWssZD$C)F?ooK4Tl0KXi=4(3qFf>=$+MII*UfhihyJG2XT)Mw^OP{ zcw6>8*!r42zLjEfRv(!!3jXe$rY({mIJ&}lPF^2zzv`X#nUG_Jh)30Ohf`IQwz@~~ z@r+A-O`wk2|0KXUiV>TVtlpTt?=b(7aL^UQ1wFO3M`{Gk>{7lh30+Epk@XkylNQ># z?Xp0&XIU7hOI&%+Y1g0TR>Y2U?$s=7NDsRz){P zxTrzr%6gYY|28WV*~NDYYapQ2cJo^3S1yiCx;3Rv?qXCM*TPp@Q-<5a^v(`}1?PS_0ROmQJ$e00{W)4ezG-7Gg8 zVhk-#Ga^`VXwhHCr7Wk4*T_?`EJI^(*V?f;#6Y$f*-&<1xy>gU1QH#PoaN!a#;-K zpz~inUTOKqi7B9Yr*pDpv~cXAeH+WdPx|o4;NOd{BL_O|TRD>P>x`&H`;Lw2TK0$k z^2bB(VSBCyqxX+4sXm%Pn;Pan+T3;vAKAVDzt4rmU*+Mb7C7Nke^EYDz47XYZHC>| zx25`O!wHT zD|XRB1ZoBzu4DoPiPKPm5nSI51mf()I>a_mH>;)qNx(J{S(p_vTc>a6gMCb_Kcw}p zwZhL@^1-4;N!aBQ$>OwwBrFrzZ12HI-utd&oM}aO*@ipN_M7o2m_itmOW43D=tZnj zNW|HOlhO9$@hoJ%`6y&kLyEXTSZKi|CyM6Ki^|z0h9cI>Wj2A6_<*Zv1yfiimZM2% zhkV@?X=Xc!z|>Gk+C=MWa$Zn}j0OOkk8Hg5FjfT}1&gCdKj?vUTROdF3u#^S<7d8;I47^)BCUFWJEqDovrW z^FX#qLK-=T2Gx-SRNkc6ognRy?ANF4Kypr3iD5^>+HQ>WlF4otLOdcWLU5F5WhMgil*fD@5-h0^-@E7Z+ z7JtT|zqYmdj1A?##;D-wtGMfUb%4z^e_&?mlSFM0m|}ea#zxAcHSgUi1nTt){O!)0 zlJUk$r}RfPhTj>3z>b!jClb92&f zw@mh|)zwj~JDg}E!zPy{MM29^*p$YnZ7Fa$ho3R0AU`8Br%?W3uT9h~zTDQm%WAp| ze-5HqYOKdOd>Npoh<$?h1Dfvv(YCovdhB<=z;D(CAdbeM&XP+)j#hmHhWK8m`>-d! z+em3q1Y|}fi-+l`#_W)9YKMD`RYKa_cqhc7w#~^^8&U)mtVdN3=&N~X&TjKYq&sVV z_@?TQS%KjX4WE=9jEau@Lrn3F8cdpz!DvJt3mXV5Y3}7-xAGMsj_HXW+G9y_L-;jL zcuOH4VDCaH$u7M`T|ONyf+jIry$5v#{m&w5c`?2CTRqJZgN9+4jaL7e#ss*>3bMac zyYQ0cVw|0L$-W+HX)qAGGoRG95({pyy6zOd`>%R;;-`nJV^#sSgl$gAfpUMfP1`a0 zG?3^Wzy~r&?9T!$645e|X!BmIAfOot?|QmKfvL~sV&L^F-%r&o?jYQ9(GlrV0W)W* zYulwY=NQ)>xxF;}{+AncYEp#S(JS_G4o8fnanE95h!6kB76QWUEF?s(dBmez}!q$YW5ZZ@c89Dz6u-gKVI?Ivw4c@#Qy^2TA@a6?{Zz z18ujqO=Sk%pQ#>hk|!VsvVJT6cKQt`!^0;jx1HpwMpYl^Xc|CKILB4`3-y6Qb4rTt z7n;^54mZ;+DAsvrjp3SqKHjM=$f5(5In5P}ImH!agKbcEBi&SE(CbDp8FZszAEBo5 zl>-q@bVWlReI}Ks+moR?EQ%#&9ZLAQ4BVzHp~Ce2r8Yo|D_qA)lzgLZ1tOllrG*c* zlYhol2O3G${s8ub8nfkZmI9N}`5(-<%z2~RHTFXjl#v2PZ%?Ei=W8s=9L{QRpSho_ zVz+KipKyweQfcjrb+x?p25A+H$FNJC^`Lw;I(hd`fDA{-Y+rhUDtXF{wNf+#P)hP$ za?tpO8x@#-gZ!@E{Z~lP?7)9N)JjPz{`Z{cHG&UkRh+#YRl$M3Vp|$j3O0Rbba9D$ ziQDX6u5a$6!QohsthlggmjmTyAad{SXd7nj;L`!UJ;A(CMWMi=&WnC+v&Jsx{j?Ho z32^P2OOJo(gI^`Yc#d2*20qmpIFW4g;x}aQfXR$zO@iq$udNZ#lhBG5?Ec_u^yVQ6 zU89dKLARbrF;j)g=o|xr-;7hN>miKpjN0fxZ%=COP?2Z(eP1-1&?6S8b}Ub3y*e@d z5kwQP!P#<7%g;UM@uL@q18{3UGIL3d-<4~SWMMh~g#Uv#_@r`@i*w@{2u+DU*YB*C z%d54zej3pW;CcqgbYZ98(`BRqs{U?aV}_e?et7AKd?wk=M0U3P@?_34#N?U@%(d>y zdL+#GkX!Zgef%>M*k!#X_Qo_7$k@ByY)g1J={PYzW@{u8-<70`Glfm9VV=mXn# zSB4iKR&j)qA?@P*ozoo!XwA?Pi03#6keQ`fOTR`#Xv2bJ+J=&dNsF8yyXVmwFZASL zfX?X&-Z|AdMce0P=os%cJ76nk#079bvi3$6;yM!P!VSfy4A67TNjR4>YP3W!K^wJF-f3D>KCR7&6Zr zQXh320L`O9?Gr-n*#a0p2khQz2ZRjFS({g_?JpVtiz~10Hm42iwLPBVSsT+_5z7a6 zML?*8T~-M~7!MQHxL?DWocGFZ>}j)ICsMB!xIJ9aC!PiCvaVY{{A$tFm%}3JeqCNs zh6?h;v|nQk2-~&-Sw!9}c|!Zr|GKk|x@rl9R9J!XaXKem;l zu#$5dDcRBrwNd`;3DFc`+oK0x3+PG2k}9@?Wm`q+12|iOOMD@i!7ko&3|z)x7zoap zeQAAnE#}L5PT76UqvNNPn6E0z@F{HeO@+ZA-3OwdXuxY8wp?4wgn{_Ghssg(*aY$CK#3-cdcdBw?r4>DRSwdMv(D=8^U#w;)Xm$7c2Nt|F zJUsJ$w9r&QmJ=j($cU&fnEDyBGB8Zd?kX-(_;0gcc}MLLIq8esg-zSYFRU?8cH?WH?<;J!(dmGtmwa_k+I7 z7IB=Snj|Ab7l74N$3yuLt0K3L)eDg?V{t&%i;RBD)@$%XTaB$V`nXge&;bCj{ePFo zj_3mYG1gmMl+&gnms_%ISs;-qDl&|H)LF{e1O-6%?}7toLwm3iJN|c<4`cfa%s`+1 zy&Ws zKV+)A-IzS)+OX6^dW=}_g;7~&^3>=axX<(P1>r%)xxi~CPwyi)Nk}|mzOCA z?&x>;jfUZ$Q|D0$w?l@T^d#9G}r*uLX2347gdtMY^rC*M|t~vn&SDvaAZYjTYAq1-CnWfxORNRSK;SN-oH;xew9pxO5@p%3hmxBH#<+bRYRQj6}eY5xKocll?`h z8$ijLL)!RheLb{$hS*#1a|!2_tu4PQ<;gu0U_62ug*KT{u4bUkkbQL~(X%q+7;cX{ zM^^-&mdtEN+f-JH<<@LYTi#&xlXAk%W5S5z=?Rm3YJn+p5FC}$J`G5!F&+Q&yS9A1 z6CNUDowt=XuZo)F08LSSd@(+>8R28?<|2V4vz<@)43~AY-3cIWqukcvEUaEKv9Jzg z@++^3Hchd`nMuth1O#M=9Unh^^LQ$Pw^LA=xIYnTb}wL**q^$!q@SW-umR zl#a80oOx2#BcuxZsMf>Zr@Fa*(#O=7_O9sKU{Q5mFxllH@;P}lRE0rPqbgj#-+a_Z ziy0?+@9@1{JKinAWY^kb$7v|YU}Ryvx`eO6l{t#^)3|A=dKY*&8JbR>R&TYU*2K>@ zWl+%YnseA@(GhPN{tx79*%8HxyN81=s^CmcEtZ-TL1iAC>rhBy7Lj{Q=4pAJPh(~r z4i0i-Dh#c$(AnnxEJ1{^{5H(fy~IYxv){+eY8>j0WGoNp%L$FwrlEs*^06iu9ZrVdvWB2VP`vG3j&5EB zdaQSS5I}%p%H78~e^%6I%)g4C8kqmiOGH44Rb;hUm1HehMO8YIOM?g%rm!6GDATI1 zInpT%Za^q=PDf6tOFB7WDgS`^A7&I2-ghKSP9MCw$g>@(+nyE2)na^N$bF;53ShX$KLCo zg==SotJy7u!)hk<8f0yJpY0Uf0t5Fu^7pXB$TJ{0fH3FEH9kCKJ@TOIZQZtz$ z@sNJ+Qsu^kdGw+OqZ^k`BVZCrk0SudE)7R{O3WNcu<6FlsAemUf&_~UqM3f+H&Eo~ z7yok;oi^IN_{Ek%^I~zR-!~9s6-Bj>u7#5F%BBw9emLlS#%x~tb;6>?!(j$b3L8n* zzHxpsq0=U;?4mL5KQL#cLb9TJcmNjXe3Y(~+S#Ff>YnKN(kfTyQ~7lkN(Ut><<9KW zcJpEZ6D*aZS%5<8{fEnD{vq}Kz}`oXxxZnA9{Hf6xR&jp#F0u8@6^| zL0(R>Kmo2|5=m898Vc{NlCyfsjbTsq*ENA>$amFUt}rwOctah}!->C7;XiY?YMjBI zc)X?oE^;o(3XCmWp@Ga<_0V_fC83{YEmnIeFqG#2frBWx4mycr0Vec+wiYFa-zQ<6 zD8kIBma<&5sRd|ElV;SSOmO;Lw^!u7FT;Lf0sl@rB)bXA7Jm-jXDLgqI1J8Hq}Uxz zR(mC`KDT+|5vyT18f!*U%H`)nUS&_?_kWb%)OyOPrk#ZfkIbJMr|e6eQMv@Qo1rrO z;dlOv*7nH=%e@wbthdizJ#MA$2$BC|!L}JzvC*N~x*=^~&BbB9J5bfto{mtTx7!r8 zhB%3w0Q+j>zXVmt3ZxIjFEKUiI|x`n0!o}f1ZHc=dd+EJ{M+YAGmoLbL8BfQr-@f| zS>sTU?L#SP#0@Hvg;P;+SU{y|PO=Q2^(nbaQ}klQ$0{!E{vyYeRxrI{x1CKLpU{5f zR1V*2xV1%kY|XWAeOz{K1x#LjDXs`XM>mSF_^w8SVJQAQTfs=)RnXY3vO4A7;}J<> zKt4pIniy%RK`|UYrm+^XKG^)G{U(~q%33$keQ|$dOa%7G;AgWaX;p!0xT~{WSMkBG zQExJd?mqHUmI!F8gG?1m^A_Ie!t&HLMm9_8;QQ+54!=|4vIw zL&2GCXyarc#k&h7ZsJhS=5FlS#D)8pWT1y$Og1jz>T1MZI>0T;ldcwryD&en3S2?l z&+_p1P59L^G5xK{jf(cU3OajX>(Qe(waMK__P7#pa+V+J^k62>y`qGH6JTjDu~yy8 z=QQH2E@n1BQXrBaYo*9UZwILSBc;w?{)Bf8$6OZH7oS0a*l(9I-<}+NQE`KAr=Mi6 z>>xRWfvjAzKKh>Ur8;@?Zpksi02QiKI5Kp-CYH!0H)QM&NvN|NRquQle-SOgLv!}U zQ<)=8O)U(f62hgR@SMbqkyPy)h`K2=o{mOin6^bAk$Y|Ehm`0rN4RId*=9wZ`iS2V zsCuB$C=;a@=B#KP`xXeSLN+Dq&bUaMX`Z4VETg{>>onJwx=FGZ6D|#y00E0xtBfj` z=e_##0ldAkO|{)L@LZ-1v41c-LN4H&qoiw=TZ^|1JBCEnuZ1RQp1(LOHZ@M+u?ZOd zvC7MBZ1)*Pd4UB;`*|iXIjSu2%9s===BiVvS8WLJMuQo_*~Q2QeyPMxd!fNo*R9td zVm;~4ay?6A_i3e=<-yPUz;{bV$-t!5F>{}WU8+V)_J*vyfIzBJb^L!P4W;q2u&0+j zlkkGx)oMT6a;BYF%7yK_=pt$)WY+WM3emXwrT&evir{78Y{D7E#qT4AXoR9b;~NpgwA$k}-@*iv13zP6Nyr+ZA0z0A zp8~{;F54o28o`bY8~)Ovx9eA~|0vHtJTEWAh?m-1Um+E*?>^MtQH!3q7HFZw%#al3ZZUY(pvDqpv02sh{7{@}61zow6J!z#Qz+9}{1UR^hjb zvjZ9*=PrY;;0S+N5G3~C17M$HMCE>W4U6~j98+ZN%e8PLEDA5fBzp!79De_$4A-?1 zWeCCB-`U5r86f(v*-&w{~9?kjc({HF068uMNKW z!zwT&8RaTMM-U%di=zGzb#c{7x(H;oOImQSTWOx!lLNidz(tTpW@Nz{j=P;$s`H`r zO!DBHj7JOL0m7a*dyJg7DX-gpuhKVwd+>B_384uA1jMKm1dNG$Oi{f;RlPY~D?LIn zMCTU6u{Xf>w8Q>k5D9KEJoSV?u+M6{U0Doeocd_)2uQLD%5R#Ba>X#HVC`VwupgJTx8tyE2Ra(Ne zhO3zeT$XtnmF+RWYg~~fRWPF!?ntuPZz0$4kz^MBDg)a=YLABr#Wn8r&9r=JzD3%n z-KdyPq&t~Wn?Jw;1d@)X$1F#+r;q9Cxit$e9BnQ;haO4&N9MYRQ zW`jA=!^=IY}9C#$u~)b`gB}tVoLc!nn%}cyFWu@*r>?Hek%B5d-k_S$wpn(4v&`f0a(X1>uV-;O%UM|}%OFk{$k;}#xf)Da?fJ-)geIPHr{_|U>V z6&3N5or;z2Pqn#=p*prRw6 z-SX?>G20R$UQJZJ@^Su?HGMt-k^1Bfos{NrWz)qaL`WR2$zrTiX#%dZfk&?XbQWVW z#2l$h)=1l`BI^U(Fd%kPyPS2X^>~z%flF@~6L^EtxT#UMIyF|J#c@qKNqAISe1{6R zhNFI!DrUe?RMwXbxL${m>TU>$Fx1_ifDVPnz+Mqs)wa)FHW3>YTNwG_6CNSIdI?G^(`gc0a z&Zk`VCr8=&3urLm$Ik#%{jK`0lRV#!I3vfvqpbEZ@_orQp?(mJjK* z+Y$xt7INh@Gbtq+vEAd?i%a1|zwGP{weISw;JmVfVJ*uW|24gW&?+xXyzVEwdhZWf za3Kim!%64EpeqO*R(AppRyf*ztq#49JBh~<9GJm8H z6mUTDX4$EaY=*M^8T&tfxgQUVEsH=bzPdnEf7lTTbPH6Y41;FA@{OvK=w0dS_GdA) zR0;I|Z5eRVmfnzpALKX@JX&`L9%cDWIsV^VPJuLzvW)q}K@DcvwrE=<*>O7-*3yD9 zvm8=&*E0Z9Ii>F6%T6=)7lrXh?((Z z2LRt#Dv9!Qx&(WRW2>^2k1(+rfYQZRiR}*C#O)MFcPk z;}H%=-thep=1^$F{N&t-Z}AB6X?iu8dh-6s-@b@S{DTeten3II`7`gFHrY3CScqJn z*fv2%-Ddpw94y+&U(_sD1N!6sQaV^mNE&%9bg(&*e9dK!&?gH5Le>mdu#^=;4BIBf zNN(3WCN;Ptl$i{%ct8a>>_%l0yX`;htw2x1=eCu!&T&5_GmPjb)C5isUOVmK2<}d* zB8M%bCi6FPjMSI6QWvDLN3ZrustICufU&ibR$I`ssFIx=>y;MY47g3YQEzhvYu-xR zk)r6+hynGcnyeLZgx`=9R`iU*@zIKux~rL*1a(Z#dNa`igTB!+&FJKzzHod%B8`i@ z{TD5-)US#P=1cc0LI4930J;320I38J0x#6oq%Z{O5hN^_(gLmaZ&dv4AMw9pe+`X$h8O9H zH~(nZo;cTv9WFY?z*l~OLBtzwDc91+vj5O&z;U8dS@ru}{K1dYP<_I#?RYs{cLJbS z@{#Y=PlX7+Q0;cgw>z_Wl59oly!}^Xm*GaXBATTebnV-5x!|5ZG1!NJca@DVd0E7zK7{{n1yR13}A1Jg(*o5iH`&14sbnNMk1`FLkCvP@wwDw#PjFA*@!+UFjo?LUyS9>e4S9Z>(rjgggnTo?q@Of z$(DZYBB=rGoGSqU_rOlh4+)nBC;+NCLxgK{D22XMykQR&P#>@}<=6eVeU{OW(R0{o zdkP0@hTuv;dx$dw>P74{ox79@13njeYW1$Wz)I};+qXmCV*XAw__r(s{19LnYl+&+ z88b42!#)`D-Of>2jH`=8{%uIf6>+lSAI`vPM=kNM(nu=UXbRU36%P!k zWef@AU^WL)ypzpuVnO>$SJ93Bw^6_!(9x~-6+XKKIs8xtDmu@Jpah)xx4?Zzvc=C+ z(UZ%6DNq^(&)2|2fumJ}m-+3xMX^>p&{WZ+exv=2-0DJ$2Gjo&7)nFj(5$JU28#xB zH9q;*huyj3?d2^Z0gUU01OeTvA!9}!{^`Ensi3IO5?vFW6a5KyR)03NhfBObW7Axw zx#NdP#)XVFP=J{5F?97nFoG;Be(7kENL%e{&xFmJG9df_>q&C$A?$2Sgeb--uZZ8l zCqmlbrdu6RYYh-)$GiRnO(}5UZD)gwNzBuG$v;bVPU;2Ib}C=(pTAh5!i75?*iy)~ zD4z`(IPMtHF3vB;MNNLdb>J=i<9DNn)SXSu>DRJ}NMvq#^UQo7_vSqA;y>t##FNqdv5=(2u7}cassHKfE1=?dvacby z1^2<-Jvbz|ySux)PawF%;O_2Du;A|Q7A!b{;N+WM_W$i}w$JHvdb)1EuI_nNUETHG zy@76LhN9~k3!U<6t2Nvz{?~Fg?t>97gnXkSIH`;)Wx7_M*Cu`|m>a+UNp0m3_xt`R z$21O_ZNji9+}Qo83GZ;P64-Fs99B~rTr*wg)Z09pO6eMEp_MKX$0t~Z|1^p3;~|ab zqtMi7WeF-K{H%5$814BTZ)7%O>C-Qm)X`RQ2n*%U-3*+4Jlg1d(k)8W*`)Pdw>ag+ z^}j%-k<=n3K@xSf;NV`fRc1$ut1CsdeIssgCu# z>%)1`$>-u3lc>J3YWw-*Af(7e9c6lJrAqOE5HkGvK(~@9IUO|ajeG0_b!FuN?1dIJ zC&7zhV85%t8v=2x&*cQAK8Dn(WKSnJXR4!XyX$^4_2uav*AUWbcodBQjV1pR5?OQy z6{QG#UM?LUYNq!}pJ*a54DyhhGVK$xOdCxwl2HgaT_71#tO83E&!VK7Y{8fixW%yB zjfS%qNJ@;HkANCd2Zj1`ih@iaayae!nl#5~3{>oesQB|LI@r?tnRhFo8;fN}8iA$Z zSQf(|T*0+2q4wRIgGD*!K+S^royo|+U@Y_Hmj1C2`B-u({%SNEQc_`7x zHkFq(kKj7o!HT;qrCOT{bijg3`->4i3ka*M7}_Rlia7uvuZeh|{DeW*K(shZc}8wM z9b!NY-}L?79YeT-uZUXJZ>(X&!d=Q=t6=^8dGhTK1W|Vt9J-qHmae&Hg$jv+ zMPX04O4ld%j*@fW1kJsG(M9ste14*L!b?Y%x#n0m6uBhFtAVRCG-$`{`@u`v6BzO+ zc5_vki}KP1Ns<$38E2|Y***RypJy@3^i0$GLys1dtZGIsKRPJ)eJ~@))*$D~!;k&8 zc(=N1lV)E5y%^nnW9Rgkvr8iUq?Z0{YN|l@SXi#V+${LK5wPE?;d5y z9ZG9^7{zFB(GV?mI~}7^-ZDI6T2kp|tVMb3Xu5_C-)lzm5{s<&42t^`2g73nd+#(J zLap=)lW`mb^xKOtrG3Cfv4aV&o&CNs9qPFN|;D-;)2ivfrEV3V!8LfjZ* z4$k}R==XYbKvSxkokfGdM@-1`CPsI{ZK2L)#i3#?vO@G~_O&{-lI|wCAs0RJLOA8X zq50>esZw=a(~Ml`@t5;M%5#P-n3{#fHFuef1-n63qCw?Wt4JH#6F#eaUDty5OAc#Q zgCmWvJl`(5nDo=|A4pdV0PT?$ z4tkJZGlLzR2%_jVpy>6F8C;r5P!dke#W(3Chs#G6m$bZ9I4~4?Y%4P}Mixqu5*EGu zGThdXxl&zz@Mez|@n%``FBDR38VsuY4K>|N0qN?DVU5i0&(hy%RScPA*e^5cZ%KH% zEJxd$KLxy}EG4055IyFS$}9f)eG2gH=u%&M^S7ISih)E+0Lyxj((NPa!p&3Y8I6rQ zwZ_S_xynS44|NzrDZHW&p44l_Bq|oWb=!Op7|~=jvwt0-87^m0+c>eX!>@y#NEabRmnCEkV^7xbEvYNM{_#abIHKAXS?< zZl(Wue0;1eJ4X-K9d8nPyg zgc3##xy^A}Wj$zUF#dSA5tpo<5Zi&?Q{YRfSedOlX8 zIb=uM!`$OT6!-fzd`%QOc)vmykmvavx55%IBz=w4joY(FbDbHb376jr1T|d2M!J%D z7@<21$OoTF_th;=u2LjD6UWs3bUy#~vkkxXA-z7l;T&s8>X<(S1L5>dCRo1>0=5c~KMb1)2#CS$@VsFt}Z zookb63#Z^Z#_@I@{GlRb05zw6whaOoTc4OUK{wc&5|8YF@*J?Vp4zE z4gatr)-H)4aoN7H$y~Qsy>#_^fkAUH{)2qZXGYZ|Y+0=>!O##r;m7%>puh9CMWa5^k!XF)QfoR7ou zAkGt*OIn$lr&!?SP2!|@q1sQ%cBUh4yaK%sY5_C`>_tS*(Dk#Bvyf|s_o}g4t^>wp z3ki=x$4S2TtddY>fLV(hsfF@idI5s5>&Ql4^UE_G&uTA&;eNE0x*^6loXp5RldB23 zho-VjW5)=Y_=~7eUq;H-J0XcIkr|C9zL8(xwFQsErmzBu;pz{c*C|3z40I+~*L|Vz zslT>s3nHwi5(~FFmK|HEaLbvUtB%f zS1gfc5oZ!$y(3c&Z1X~gJ)nAU)(H~V|GmBRh_}Qw9aIsLFxuwuM`s_BwR_VxMImNF zycVYuv{K6KBKT2FR^p(`Py}l1G?!?SumFc~w$&hNU0w@kCNZW5pYQ^J;cv&>nvGRl z$`wr#OXyHYW~hulGk9;Ql-WFEI3wTTMC%sym90AA(}4YCHz?Hrao%kXMJ_1J@L4B% zR+Aqz8olrfELB03MT#?+=>x)lj{>`@>#ys;ZyjT>;87qGNG}fWUw#~Lp|MoW;6Ofs z>(|R>X7|lVCLY)Db2ym-XS#OK_C*UHgRZOt|7`9a%T*K!CpuT+KQrA!uCl!elUNak zJ{P=4?bEJ4)9vTehTA|0S`)5No*~ZTHZsk90zoiwQ2c!Sa8V>33Oypd1?Pa0nzQO4 zQ>fRZLvx!tkR?5(H=cR5K-mi3z>y$CXP!~uppWf`Es#|f8U4Qa#LU7ckKbuaf^2rY z#Qdu#KC#7@-QF8aY*_Qj8r6CP{1v;_icqhiO8H3Sq%#6XjW*WeOE_$8X3-y`v9@bV zW6ZeSI;#iF2X`pr>;pO={@71#A>ZgZy3&vJK}>HgK%01)4JhP(rIy>sp2HC8R)UZQ z2D!?5&g(3)32{-JjpU+~m8BAUB8-7J?2ff4=C-|k^I!7?Ew5>pUNqlh0ySFJ3oNQK zZw6HKq32c9B;QFBS+dm@l32QJ4An)#5JhRJ)K~4lFPn4?9IWbeLaBE^9A;{*49GC9 z)6}Zi1e&Jt;UD!TD5KsfXu51zrU{$t;#2=V@KUd0{cJ4YwZ_r7#VIL&RJTm=Xucc;sPEC+qUgT}j3`v%4 zk?6UWxceJ>rg;W}vY(G*Yrl;*1_U z{_WEo)_c#rAfiOE|L@96v&tXvYRCLOIgcSmvwuX3grBlXVKibF-5X|iu#jF(Y@v8V z2ErS@ypzD_xBMNW+(R&}Pncz=BGItBiI{Oh!K+7N^SN|H9p6Z!s@%J7M!O)4Rd`CWyQELD}%`QK)XocPS29tK^k+TS+ z2B5WOQQvfXPiK;ytM3D{uVJvaeH*c-Ybq}-cl*+sa-QZR1N=KhL4y30h^s!ca&tLDoCYSlSY_x~?pmZKa8nE1F zQ&yh6LpR)Kkh|0y9S8$d7piY+gEP9Vt$~qU#QISv1sW9!^VQd>C01`*k`KI}vPEFO z&ks#Ku6=-2zc|&*p)xYY zvyXGsPE!4dTY6pEaKx~Awh5=8$xrih4hGRtvs8k~G-u1?)$I%HF%c~pwM49kXw$q^ zF>pL&;FivWL^|9!Ek{sfwA(oNyBfqdS_ct5Q5)ssm%tH>Cg$lZ(g4^KCVuffOrI$- z$7zqUr6LZ>!R}U8=IoEW-Qi1dJm|n-*5^RFUya!AbVlzB5l?U&TG_RSh!TU3pf(1f z9~EYMFA^xtTWC9;jXfC5$P9`~EvII=Wi@|e7RA=`nDD`;p!euWHOSa9R-v96^7>aknEDh#jOiz(gWobttiC4aeL)RXnirPTkaOASOm7`LS`z58-1b{xAK8i3u+K;_1;Cruvi zG8gNP7o2fVfqqMl-?X*wnN8R#jP8 zqlk+D(HyUva}DY(3=Un>c!o`^$~Sh^8I=N5{`FFxK{jl7EMnXF_8 zBZ}G+k55o8Qmxu? zkF3;^t+)9}HSWiOE==V{y?5q){ewG=6-TkK)hjinOYxDUOprD;|0lZl@-U)tgi|(J z@dm};eUg4?7*s9zIok$}N8Dx|RA?$Ghj`roSw77VC`uR%pnU~SCfKZhn z!WsoXDVP@&T5gPEj_pdImK5B^G~h?zDR!oheXq7*DRq@M6tBCwOVSQJajCVakqq2l zBxZMeh#9#SRKX#djv(fc!_nE-5gbGbH5;s5Ll$I+wXD5ZYCYRQ*>C4NB zpAD&MW1*$OiqqqfxX+jX|spYTiOwyO2H?opE z5LLXfG;%zUQ=uP7S_7$qX`Z$qMi%0>CebKIYi~6B5NgKk9v;hr_Xg?y6W-d`BQbZ@ z8UCChG?wM-l-`+gfQZWvG%dMgfm^)R(S2Bd`lN_fdWX5e5d=t(0(u(z`~1snH(ZL7 z(s&g%6%99`B{_q_uhM~ba`GgfR)sTTx7jztMHOHROgecb(6GIE(emYYa4=Rmsg{~@njL7B0;ez0w1Ct;{M&JV($+X!A%|gYtnG5j;=l*%dGF*MnWbwU*!H|#ce&WX8 zZ$B4vK8{B1I2wMP;@#%uZ#C@h`VDV{Udi{AFrPmzDrz9A92O~gleTO$W}ApaAVYwI zpLp708%BsM!`w5Tr_t#Tj502>=vbOxn$0;7#`S&g9cpVJ%G1%;TD<98u^ULlYUU9u zy6$?K@7OUJ>FLJkhwdZvMcG|{yq1R?p0TfbQJ}~AgMa;{mPLQPd>=7_ry-6n^~<-L z(%NY?iy49hadAu6W>IHyImtG84vCZw!AJT*FXfg*vu&8R&JnY*8EU946w?jw8O z2?}QaGWkqgfyfLeyF{u@EvE40u>U|DP1uL8L z!>T48qtM+gPBX`rwPo@Z;;35w1Hu~Mb!v9`ZCp-;Eu^W5hF^L~>SpTW>K)YNz-QSv z1i4+vO@;yWJuVUx>cqx2X_x4iS6ZXACWJ((du48z3F9;VzdPV|ILbOWu-A5g=VaOc z%*TG4LuX1>-FBP<`>z5W7LqGWYq_iPhLVw;+Pwv9h7>0o**eAq@h^|SPkN>&@>{CYdJiV4-;|lA*T7{igzaHjg1cmGK%i+(8Y;KTE6BJv{O)p1|GeCw|ySG zUmq{3Pc~VpP~3_zrR8_xYghhoY=DRTB+gy7ojbp5>Bbk&EdJyFM;41P4qD zk^ZZ#0|>?UBiHv!np54&cZBt*%nl94P1t)@oa-Gq!<{j5=*B*v=KH65qhs_d42SxI zhVLeLWl-ja*R%4<(^^Z zG7{$}76~fswK``WP_z4~^ha8|N}zaZJJbh*ek1+u4$%ZKaZI!)QR@z-GfmO$5Z3r*AFpo~wHY4F(ozTF!bDmTjF@C+$Jzhbh@A z5$l84^GlU&349L%qTwB|qAmycz+P9J)|icqql}hMlGlA?DaH@9jnk@C`!rH!y-o6t z37-^6EbSC#46f7NL$4ZCi}t>XkJ%JAm3PEFmJ~+N#mGXOzoaNd% zXm2H~$&;XL{ex-r@guBhsq;^q+k!Iwy@CXgkbUHuB+eL{JKN|IGUyHkcN37ai z@9?ymtBv}g6V(iF=q#9NYp#3BB>9@3!g#`Rya4pVD!!K%LV)u-t&vAw-GDqU7%x*> z50wGwTc{tGbVrBJh@|TKXY;CQ%7Hv)?APQILYo?dFm%En_)cv~$ zTbnN3z5T0INH0AvxjE9MVKJX2FS=YWd5>~jFL--=FH&2lJDmXE7`>Tr`JH6AO4VI~ zLj&f=T+q}4$6Vr+!vpV~@%KIqfM^WZZ&C`=p=@A?27WDocG(e0_BPL75Ek-a+r(kgMHg~=;WVZ60biJv-QP$X^ z1j)P!n?wxJrN{3s=F2{qy5?U9^mjqj2iY+tc8QZEb_opr4eHi?KikHVKHL42Zq%`3 z8^HN!R1F_?e%zo$iqmKsRA0Hi-z(L|MvM}wn=0^n5S=%(UzeO znQW|SSI4ycpjuPu5Ok2tUQHw8hcUgK5bw2O+*+xR@i|8f)}=Kn?-Imlex4-6km1eq z@FcadJnN8+Ph~?t3_aPxh&Jf5*o<66(IvDwJ#DEe*Ke)4Xn+j_F2;TXas^hzda#a)88p>GtnvamnbrkM+@zfpGg#&^3v3ckk{Sq@t>Cj}%adI$Bm|-6NIad+6r?@YSed zUw(L+%n>;vsoW$fDla>KNWN>TOxWp5l!rQJI%oOR*jzN4AKZD^oy$oRg%_l=nFD4A z=R@z^NaU=*7GNoUm}zNj>O+`Dv_Q2noBhmFid9SGU~Ko(NP`v36gn!U`hK#YTL!nE zEJJ!r%EVFgqx1*@4jb;AiR60!OIB{i6ekFMZfm=au{3$oOO=C~-Q{Vg#F9Yp1BVvh zItUVU&e;&$gLq+#wbThZ$=AL+ZyJyHFc2hA(?HQ)h=1?vVn~lJgAs^RiE>Y+p*#xl zX|-=-AxMKmu@#kT3D_9n3|F9>)532Swi;t|k^4J0#|_nuL@*1wl2&BMTYV zY>E*N5$#%q{VJYJv$8Hf;(FXPP=mNd_%+;SeLWpP;TO7Pl@#n@Y=oHZnHBqLmUgkB z@4{BiG$k(wyL^v&Z<@+#^ZW&6ryeG}yOU}hQLNhBY<&;YpWIF|h*%cG5G^mXc195ed53fPo7borK)t1t5PHbRh>v~xt^<;xQlalP70^Uu8z&Xo#C~Ha{=IBX!X{34 zP%Q!?+?NAL_B8Qm3E5v#G~5Zc>aN*&J~Qidw)tgkwcOufWgU_-y&AP#8qb=)!x}&O83xK=Vj-2LYTt;rYohy)CO&@;Fex$69Jj$X91vO(i)bHXnjcStvbIX8y8BTu zE47damo&EzTO!4}P|zT0wfyDAT(+AWj*+(va2-`9%=E}tHcWAYve!u! zmelkH$3t4i^jP8-m8^sFp!VrTV+gV~YV8B67Aw#-++Ab)SZFI;#tsM0U!W}p?X=d?H!SVtra*|^j0>8Fl{Zu2k~-k zST^mmKfJ0!E%Ew^1{jSp$Rr?8W#St4w7-5`Lp#{2qvNXB_*z`Gkf?b8oy}*(SRZQ0 zPCJ`-dF}lrl4pJ1kM=s}b(cBqZZ(%*G;ps`;OPh%MFGwa3eQI9{x=3`*#o0vM*a@a z|4=jdrg7dL;uV>(b1~`i?PwC zFQd_90&UO!o~%K;IKFW{-W}F+i0)Yo4Ve};SF_f%S{dh7LZ@5a5$m)bi%ApM3#c{rQIhBB4oO|N!(^(y8HHgawnfVrP|0a6o_}E ztzS zeQ;aq4P|%t!pF@ecbHED zOHbfC!xDZ*A9m)RJE=gp0(XY=qDP!}A>SGR!~)?gjoag`_mC5A4#FD05CZ~k$VUbU;2{s z666pq_&GrkZ@EZonA>f%KkJw0eijr@SW*CL#Ps=4FhTA1dQ1dmTMjgcv)8T1@_V0y zT+J`OB$y&vwWiSRtDmXQ^pn2C>CDvgSd(r!CGNf$MOdfzUaD@O+`)w}w50Cn<*4Mf zaDxJ|KMkpoB5XRx?0`M@cqIoj+os8kjwX`TL;)G``lePg6rb8#8{nw0)G`d0VqC=a zwqg($70a$4{Wr@5KKu2sSTn2lZrb?xd@1E?CDm-N`hj*hNB^ecfxVQLPCgkd#X;rb z({JBCx9D4d6jdHoRGF!DDROP(Lr1PkvKk#)!@)t{!PdBEO#~#P&&d&oS#QRh7E6ni zSTG^d*mG$)MgInSHh_IBhUw5JQPNL6v}qv_$>c?>xd*orNwQD>5}8C|MEDkMaa$lB z+MD9M!i+gfA7TYkIg3ZCS0zAClwYCZM@#T%8b@iz+)2 zJGj9Vh^0A}Jyd5$<>d>%WW666cf6v^F-cKKM!nYeivy-VxUpYzEph+^$J1f(ykQSu zb2zR#1N%!^hd+Amo>}iJ7vj#ZmiSIU+12*Z|X$` zso=^}$l6cqN)lRGg(@S5Lh@`i^u{T`j$r|B*%%oQNq$in{@fV85=Gp393C&T=HTSe zD3U{wrVcdRQu6LxJFobK<%PHrmbL(sqs_GyY35-NFN$e#WYlj@Z1L?Gdze*nM(N}m z`j9&HOqq_cnm9M}*^k$*;I=+Y%U5iHnmYi^-WAabSZ?3iAA)vxG)6uA zkWA7mK<Di0UXjdlp>5+}GVA`iHO~^oHzh=Rql1z^pa34!RC2-_Eut3iy zO9xK!W9~KARvB?YET;&^i%rr4I*5$AQJa)4s$NjB7kjZD%H|pU;zO82_|c}gzI1rC{_jLyq*|kLKu)Xf!SnSOEoMbj~I?rj{tP zWZ%LO_Ib~!w}is`#R?pT4wPRK%O8ndPbr2@fv$&?6hDy-&@pQ<{1r!}I;k7+HDs@V zp^W{RbCIoP+*>KHUXlj=ao^=T87|StKvGYnDf{*zQEllfH75M2zQVCR#sI>NSE0@? zBwH7-s#Y^)6b4U?eZ-yS&-eO&C>6E9- z^ArwIC53wx@o(B)-T~u)MlBBq-Y?NWf;LuJ;=bvK7YChR7ye#dGj_dJ)wMTUr{?B(RYdL>jT38^qm=) zS<|{SPEdvvU(%7vmfiBPpHZn zDuf60e!vMpjL!g_GQBS6hE%__bP-m=VRG8PT06M@#Q9nYw+&|&cu$EX&X!A_OU73Cs9%_|+6(NsMyZGT`Ayysui27dE+VW~MAqqnj`1Yi%se zMWw%he{#?0`X~=nZg-*rFh7z^@R7140D0OP>-fPEO_AuNFas#J0IPns2w7R^B)EE+ z%}!HIf3@>_Van_clZkL7YyNivDEo20ihmrg;$(VTm>R_N9)hZxodAoyBUIRVYZQOY~IBX#CBlo38N8^z5iH zuJO_ch%$I?(G!-HeOBw9*%teHmqjxC2K$SLALb-SxoXrSg<~Q@i5w(@qt=75pl&@Z{@QTP-fR?%bB@Ya^*X_B> zRRTW3MOhJnwu=Vw{inQznhf_t6l)mgR2K`%roPJ~mi5}|2x#jB@!{Yc;kSu1fR zj%^q@cQ&rfxyL}?O(0H)L;jHA1r!W&48P|N8EOqaXtli}(!(6TMErbN{ty7YTzLG= zmg@o10GCe~D~=>$_>?}N%BYmfF;n9M5NPK-HuM|LG+!=!Y~% z_Tst7l# z9kc}7BUg#7k+ms}?w2d+{*tl&1AZYmnY=7EOC~%z)Uu^Eo1@!Zy|1j2ehv%CJHY64 zF!@%uwJp+FMWMw~Qy5m$fGn26s)RyM8XIiKG#z}57M-Zq^c6LtD{jn&>Rx70D4VUs zbk@9UiB?DE;`<{35n8pZa#Euf1}nyE1!48at|*&5=#qPi#eb?OCCNk3e#4Nx$2;t!=@C6e3WI&`(BNC6W3x^%t>0#q1On z*5lMF`%h895_Xj?LJpgM>#1wmEOVz>@x|&g? zSnLV8F|>5e$rl8U)Y23e1IjHHf!ue8D?4J!PHyWs-3FS|KORkHxds`yz5-8FH>)mi z4z){(q%kK$8>7U>m%OCam%PU`)u#I$Hw9^q9(}ULJM$LfYh%WYT9JoN1#&|A)kW#J} zNN#Uodp3Trd`eRShh6#DSY{MUwsK?Wl2GBjmPmdAA;`q8XzZ!yMEqU_>C^NA&EeW9 zJZ97mK(2{C(PNF)QiCboK1QJ;5gPt@g_mqUy@Rb6Asvcso4&LIjO$s#*ooQ(V+RyA z^uCK&=9Yx3Hchf#3O%rN8oE9CgWb832J@KYS5eC5!ZSU`UkjmAc&|eC&lA31>^4}QUOE%x?UB20KDFGzV5ZkMv^Yc_XZo3- zL4SCXSAosJadUgZe+<#q75Zg09YF<9EDq(sM^rd5B>|4LT%`g%;86}_MWav{@D-?BIu6G%e{fflOuyq=C7sJ$fI0BOEdU%^<0cvfRwbRp zWnPITc1F8LNA|Wnp<~f2&#fwKy*h2t!XM={!99;u80VJxH1*hWGPGbSm$~?Kp?N!RI6yLIu483?qD$ zA)$^8QcdFO&dyerDWz+rXj16Ui)6#zr>Fkxd#vUxF$l!ednL^NO_d{h4{F+t-UXTM zTR&0E_z+}NzD1{;7zH7hF|2U$5@DFcVD*bc!(THsZ6&b=y&rB84L#;r+~)(JeVLxL zjK8G6$krDnY`og>vKkp(_$YP7=<1{;JB>>R6-#swB* zM@3K+T$?tLnIq0WkYt^dD$vUv=8#*EpbuV>+*IQv^Fl?T7b=N==m;-wyCP}&x&h3R z$vR}dF9pj4>*XG6OW4VvDVqF(aq2hap~ns`jRQ*OW)eY7_xRvUqU~bo`Jt|a3!l*% zD{jMiz|zsgbVflXW;q0%vsgE_m}GJ(P_7uD8cOoGnOeVY?x3*nPkz@-(jjc$Qmk1U zHN(}$lB6k{mk1jOEVwi^xc2nDaO?k8+^~nsZ=LFl(%Ydl`s*TwJokWmE1#Ky1)ZC= zN(|^Z|N0AJvzk!xji_U3hHY~OKe2e5qQVzOA1EK$hbXg5Xf{FjtLD=q#%4~k!!Y0* z1}@Q%4KIS2BwUd#I<4pIAocguajzxw21RzHU~!=RMTwj=cZj|hO$l&RC&!3zs* zOwwpN;%^Jp@(4MWntF87b%*Vhy@?ly*$hBtlO_49E%p>@F>UIDm9slC z2B%vluT{z*&nW}m8!WmQsa#uS8U&0M-tU`@+fLILmvrx~YP-otkB}<_8htNxHT0u6)=)l z2h%cz^hWL={qI=8dmF}W4ICXH4ub6B0_rO+e8dvmti6y7G=EfrHpUJ%SFR{~fFuvM zlG0$){?glO?5P6Vab9wcv0Hj&w-<$)Rt7D-UtnUM4vzXWj10vNne8d6)Lmz3xajAR>nD zgq@S-rfw11RR+41({9E7V+~$And(HF$BzJ$aP~9R#UQ|hWCBYVn1;iJ71}>mbc|WBzuhi*-EML6YFkL(FTGoM;TkT^?mwG(&I!b9@F2bnC4&VfY zAuWpPgYA}Gr|$%4VWP7=7E=JL0bn6#yXk+BcVGoEUO&t|Bi`53Qb0@sHSI4BnbyZ0 z>zHyZmprz50HB81y2vmB3)ad!VL6(^N1gs#vCZ$FN@UC9KF&|K&@W!${UpGl%%A;D zcuAL(d?#-}hPtj87kUxR;PLJ<#kzQROqr3}FeERjDt7Lh4lH&6sJjxZx4zhAua^&` zl@-QAk^3rj$;>A{6T@>39CguQQ8Ih4yau_tCzP~DMsE8Y9w2_!nCqXQ@n{%CZ=yCY z$(-SQ~@FR7Q;mofx z_8*>SRIzym^moUUFQ0xWeN$4sKdE~r6=W0RE6yPM9#Q^1e_q;R5J>g5RUaQr0&ob5 z2xAT8(xmlB?uIkTQ4vF2rW_f7TqF0Iw;97@7ps~sJv$2Za^ViL6+-#iMYi`dUW3`G zP`{UXVMVfGXTJXS^S&77upVfEhkVr=LFFnU`uG1Z0qS`SiTHul5Rt$HHhljl*$Kk< zCi5->uhHO}8zeO1G>;%?saF?Lsgb?!6%vDUhT|Q0LKzO;_PIS72Ydt;=;MG4LJ#h! zqzL$zCI~d33YiT$8eoI$1>q0!ygU1sI)T+78i1Tfp5X&n83Px_9Hl=hx0E`Fx(%I z{tzAEDZ-&$GgucejtByR>@Uy(D1V3(;2kJekPqHJ2430zDqqC|8xaN2`4shcf+_4G zkRc#A{sN5R|EV)FKIxx2Xa5?7_5oKX6kMIZFK&qxR6i^Xpxzw@i`IifK=B|Tz*F!4 zwPaF&0fZnca&!>S2pZsN5AU!7T)Ylm$^Q!m0kOgS=afoJsGx8#kbj*<00@4=;YRyk z;1J6npaK^hXl4Wt@YDRxjv1^JVa5OfLHd`)5bpm2qJTVx&;b9d;QAlzwd@~YofPt) ztNmL=^FJ^Mh-`&Fz<+6K;(=C2(Exu7H2ycZSn2=3m>{MxG{CZDzKJnNW9dIMiT?;#{`aEn2HJI`1$|sVgZ@K_ zRX2(AF5d^_97q8=ox}zFn|ca-zy7XkjPD=NkOu)scM1*WUz|BkQ`qmgfqx40d@w+r zQ)qy{XiEP3%n*!0e}GEU^8o%mkO5bk@h>aEeIVv$au977@;~kb_}`BI zKiINCkllhD;Gd5FpC`i$xZ=G3DFtu&4TE-i`N5t`|4wlf#J|V}`1i8_Tng^p{%gq_ P1%)rt!@i9Ev+w@__{uH@ delta 48690 zcmZ6yQ*fqT)U}&V$F^B$mIXx*h#Qtb^tT>I#3JMBOF>aP1^>6IBS~!$s zt9aNGC0i(cChX-*PesG!C^WfDT}@+6#<*>JiMg#=IdC)D1v(12w5$c)z2H(OCpLQ5 zEso11MIfD0lYJg!w>p`Hxx;*0{G)iTb6LXeibZND!u1NVrPl+Bz;1Y)e$dcN#RYNY*E*_Qj!VYIr$-r3lZ)3Y# zVv&~&xZimIKgCtneR@WZ=@A6NUsxT6`sSKk7r>W-cO|3!QjyvBbINK)VbXQtUSAhv zYhR|CRG>5_U zUhUX~tz$AxGt%&cs6w)npuZda;`=5iDBW+kkz7DeGw{oVZe<7>j-i*}+qV|X6j$T^ z?|=g;i=UKho1JSoX}_mwi~1HB5WsUdhlJWKmA3nUpzqZ$7Xw3PkY%udK(RWTBtKdK+N?C z=7paTEj~!gfiUunV{;6|>o#TRM@l z32EUa(7zA~-^>PKuzU&o$7fk{H*`jRfP4ePj6{?vRn&T)9YGljwDAXxzZf5mDbD>~ z--NB3Q$KDea*p=OM6+6oD(|%Ch#zxhybDk@$tZqhIWyFj97}8t6ySX|cd(l};kmS1 z3ku}9MRw}qzlMXoXnSQv?#m>_07a4YgJ#58!WqZ7`g!YDmcabid}Q!^-xi`${SdEU zuYVGl1Lk}lCb3ID#5WSmTj=V*Mo>$0iP)%QqUnXHd83{_hBPndU=7iR=xN+r*nLyFDZ+s8s`L(yvm5(1P+0B!t*>U7JS zoZ&0%OWNR?8v(}W0%E3^KU_&K;?Mgxa5t3XheP2gQ-j|yTB!Yw;1iL9#eFAIIx%_q zRIG<2m=bU1K?6+1iR5CdPv z#L)xH|Gw@qBG?K|nUR%Q)FcmPat9ES^z(kee9NMe89fb%CULEBKi~=>${}S5@<4r7 zEb=T!=hz<51z%B46_%z>jA%Jk^c^X0R{#GgZ5vc6a}*hv9K!s z@Amh>S3mgd&O$O9I z@57Ra5B$b46AJ)wr%wb{Qkr(Q*3M~RoXc%Mnu6FnK)oDiG}+aRI8>owp3(HwaE!j^ z5{B}l08kwZCg#Q>)ftDiC9f>QkGDKP6V$1}<_vQ${L2FuMKGY8m@#yg3_(l^2Z}f% zZV=IvkuVVK|49EI<>StL?#SR^U?Trjb&mg|ya$>Q2&-av!9*MhHBnHAv{iF7d*cX$ zty#VLW%}zbMj>Dxd);Oa`9>P|8cb6R_HST@|8buCwV=1v({a7CFj&g%iWC|zK9U>O z5?Z^hIUzo*166~=hCUa_WnBux7k(Y+zZ21b;R3w?)`Elps*-$k0ea8$~B%Ld>FKpYc?AD0isU{ z^GAxlIU)E5Ufuykt4qV*o&a4oC=Q>16aCLS10hqms_e(#4s`aCh^TMK`de28)x)IX z^BXZqLyO-pX`tP z0PUmLD9$%~f(+*9$PP(A4*8b*KlAVd15d1E#($zn6zv0WJ|bgU_{hNx3`p_Cp;lep zQj4G_f}|gE@%j5h@V#r%DSGz?STAPO4}}y{CgR6YGX`2+JIXD{%^4j)X_JvGOJ?1e z2E=8xNUz*3nKkl8XpjaWan%-yN*_=vc>kZn|Hmf3Zc(R^k-)&H2*AKd|6iLR0lB*H zCK$_}g34=~xN%a%S~3kLH)g-F-th2$G9pN!fkPyr=utG8I3%=?+nREc&rE~6>2t@6 z?{O$@w73c>nNch0JsV$yW5Oz3lrcIjEPZl2CI`)PeRkTuDGJ-p-){xA=s9APgC36p zuL7TY{bzcvdmoScUQcJhS7@1V0GXZ6VAF1m!7Xcxd)?J;hHUMFGvfA@H~LUnH|tOXFNELwVz!ef`k1qYXKx|D z1Chc>Q^XTTF;Kms!mKytFYK!yzKD{6x}(N8FY+I$;q@mk_TTkyxkdi~0Nyu3z<%fY z=DGC+y4z`o_*RpU-R&s0n-FV?TM~nH!O!&`n z2jWY5l-uK@KHM2^B_iD;AjY_VpDOfM58axK20jlNcqa@V83l*oNmfOodN`Avr>UJ! zI@KA1Dl?wP+#kbXJjGB>V6G&lwbN!vB8F)R7YF;?6LoGFs^eP9j?8I(n99&$X?UtL z^2{uT$!Ts_tD~Pw(P4S0Hmh9`4E(5If5iwGmn*+)5%F1cXJ&k``p{j!O;%64_5*(` zHXlV!KpZkdej>&t=A|Nmf;@XOq`uUT7d9m?MusDI;rqug!rg z-N?kv?s6b->&d*roslcKxQfZ+6n6IC6t{D&|1okK$!V<~R_MM2SVH~t4#P3+e-&$8z92r<;>_=MKP{I{?7_@OyDS%*mgwF$+apVTdX0TzZzx%t znpKmt%1VlI;*Gutbeqzgu8?PT$gcC-DwZwkZqU+mn)=G}PAfxwtWqfU*m9;;7Ijrw zZBMywpRhV*x#gZ7D2y~us1{5bZPGHBiPs>Lj8)P*dAChI_!?QsXZa)zK^pcirMpX{Wq)mUV=RwQ- z=EtV8(#lq4<&R<^-6 zH`VpWi6J%=0)M$GImoKbSms$Zf$7^=;rv@$i}kF1H_fDrso}q(26=b?I>wb@YhY;*_Vk&$TwbLe$?%% zZhz~?VQErQRYzzQOg8v#9@@aKJ97SWIUbEGd^LFBKzGn5cf5pQn}|7UH*I`NC&7dLVws}M`UFpeB zeW)4Loqa2Xu{er_$)sKW%6yNED0M1ZadO`jooJpaVAaB zsm_&)Eh(jpApNZ45Km!}MeVX|mPYr^-Xoeb-y`=K8G`po4F7fJO*dh0R!@6{`L57t zykQ;vy+|89uNeT70j8HeO~pF9{nka8x_`#>{@b4y#DOIn2sRF{^>Fxi47( zD*=ZUY1A@5hc?1giM@KT8llKa7H@C1h(SG+L1k9PLp!3#N*b=&bMevPmM-1JdZ-s@(?`Qu7{?(&}6bKP}{` zaRufSR84APGLrWUm|tath->Mk1B|IM)%tP9E2(S69fA;MGR@TT+~g<}Ok-@1(8ty# z8!PLCQWH5?&7*bu6>J$w@u+T_D!71QURR-n9JMD$$@n*eD&Es0kMS5fMV>5~Of#h$ zi2mE;2tHUA?Mdch`UWn|KVsmjy#AHv^9EJ+Y^M~RNP4}8%#-U1{T@F$ZI>q9zYCY^ z5!XvN2fw=6HnSSZjPZ6^%rrx`%l_29QRMSuJ)NoBd$-^_YY*bc&CK_%6ek15J0PiI zPv)XbAy*?ZJKaRLQjy(orMjKcl;e-kb02c8qgxPoQad`C4(>BDi4X79#hrNCIEp(? z>kQU7R`j_XD-}Sl{+<5*d~2P8J=&jb#U=J|#uhS7a!O`Kl?{CDE#0=Qp`hgKbSO)Q z{@Yr4$~v0ie(-ZfswzFPq$uFm`wRTposQ;@&L8^GY~ymyO zC57X*12u@P=@D7xRMGMfjXC%ofGZVz>P%_$k=^vu=OFV+inc4CxzqLD~rJdc9ncm+Ivo&BwCHv`m zk5%jvj$1x9iY2)W#u|{yYptvq=kwDz>jt5X_$)NhMu-4X3;Ln4*hO>+@+GbFY$D)$^0~{p{k#CITp18xU?VEr!`TI`+UX znFzD7n?Lz!^idcBHEBMPHFkKLL&`nUF%zO5x1Hji6#1|>?n8$JF)#efLdNI?g-b}^ zu>xN0W#voJ$GxrA&sI#kyRbEHDy+Tj|JezpR2cZhgq9gPPv1?%tMsd3#{ViGz@lJ5 z_=ZpF@S8WsS{Q&VfO99{r!>bleU&f~W-x|5J3sCVpue&I^+d+hXt-lTAPVtPYio~9 zm>Yw_T;WKh8mj3~JWKoKzhXauy61lzc;?-XM=4X0hoM>Or>4`2c57oagl>@7x8dld zFQKXbuBp-HX(U;|h}Ir$qCct`+FrmowuumTsU^2Q@(F;7iQ3E%s}&YE-yo8=w_N@Ff$Yr%64WGyX>R7LvzwGMp*5wt6!Z6$W(iw{PZNT&t~1x-$$HbL_Zw zYew1pwgkH4a-6M&e`m5j<St%P*;LJ6z zU~C(zKQbKEnYv?f11M9>WO!Ri!;H&~oJ3ooNZ^vWZIN@?0Q9ujx>)7xD1WK9!KZz{ zWj(v~B&!jj-@M`~r?}>C$BW(>-0;!Dji0W6CKLuj;Uo9+bXUZ>oB_^rqa&WqVjnKDku>gvOfNIj2B|4fxejMFht4{KtDF z5c4^-9Qbhkjjt5je{K|bXN~+8i$=;yFai%RRPx4-QN%-%QC0ktcDo3nD1-UmA9 z2U5>>;t~ER%{VmXedt>zu|ra*moD5rfHUQZ<}lCNV&u=exDT=&?Je5O6Vc)=Mg5-i zjS+5XQ5_id)18YMGjutfYE1c!kOq9z0fDs2iI1?5$OJ>MVmkAuKvRNhXXruNdAs0N z+OSgp5z^P@SfAlXR}9dF-FPfFw?jJYQb%C{+7Z`)ltd+_qRjvFMV&cdpZfvsG)FDv0=)@CDX0g`79+P8xO>qdB3aS~dYtBcz;+?5PrrN9z~0_d4atO z@0P#WK4KJ^lCPE|9H*;EreT`3iWJxR(Ptzx@FM)bsl%cY^JelnjxsRYX^Bc9mbe%+ zA3?kKXENj8QW5e=FG&~7;9CvL&5rL0I#pv;jKLUB%esZ6)MEgszK1s$YY|y~%YyRBUNJqQ|GTx=!ndyt1tPT8X2d z+*-KXLiQ2aLZ%fJD#}c#K`VQYQ+Z!OddI1vwV&!+Kvz>Day$CW3x99Kap3l`zc%oB zElvELCv=@JZ=eH7APSaK!Hx&0Cy%E^G+sy?;%)U6tu0%>Ey^-hqsCsm@#Hkn7^FmG zZW~maz$Fx!qDl7swU%wXIPR@Uq?eSkc*q4BpzqJg-XiWbg*K_)%X#{QL~F zsm~jFCLR4Lhl`~m(k9v^+Q=jJ;6)@2m1n$4{fH&NU$;e5w;`{~ok$TfLmG=Rh&|tW zh2g8B%W?I`@z@9IT3l&5z-Y{^jA2{r{m4`xquG{H|KVfxqQ*IuFtg`K|8Lbp^Jraq zj^9)--Kc|fe41R77Cz8sV6XKCk<~KrJ$TLoBjVCBb;G5l8XA<$=<8H@^*)c-o&om{5+yQ8MbCh$=F?AWCiwdo& zfSfI5v8v=PSKC<66351N$rqP($#fAN+`=_78>|iKE8Z80i8Ci_6(163G^+l{hLbl7 z(*KlYbKUSF7)}Rygr}|;0$2uH$L#v7@m*wLc7JGGSnWPkNd(K zL^{ih+X}VN41Vd$Q_mUdib^{7G#(zO{ zvU#Zu_I6PlWc=PYh^TdD`oL(498~7i&)t$0P5QHp%qSO@9V=`jdAcTO6i-q~7t_c| z)_Tc4Qx-VfW<=NYyL{A(qk>!4mX^rgWT|1YfTHE zQ~X`b&N)*FeHV*43F}~pjB|OKGRf>2^HqAnb%M>$jbTy;4iw^e%@`)MEM4R?QfB6m z+h_DiOeHprV+crTwg29_o^4cteU^lZutUqwo zM|yST6*FJWWic#~4O0dA#OLKNv`*r1wiYBTzYP)i{4{5FI<;F7r(L%YNhfYeycbez z)$?zH`BA>F3rjedj7He_3u_2rR1Nb!xo`YLx)`Rq7+erwCFcwvGL8)T9_l2~t`=`* z005}QMHrhy<1z*CC^HOXLOp%KLu5${-UY6!TO!i+(}ls){47d{cU;)Y!}uqiVd8dM z4@i7}Hv-{5V@@)i&Yqmg&`SN&J&3!Q-Cz^t*-{w46O(u?@YO^aNLTGM<7iq3uIabA z`k?BUL;Js9?c5rWrP}O78L*`~kYK_Iy#W4nzS!$(ut*$pUiVC(zV{t;OS6W*k4SGC zOn57+T`}jM^d+H%EL(#xc#C>NG=fBf^INKRynQUAwqtn;Gr6Xi-7s| zgUp%fX0?06tQWuBMNe`@f)>U4-~S&j{11ACsPx-3_>a)5CSQ_p0s6{#q8MLNiH1v$ zYBe;iAUk~s{Z+k7&4r(1 zI;OmP-H#?GuZ4w!z;y z4#>mGBP8p;J-vz20mf@%bv%}mW8^cfSzeX$MQoWj`pteMW1n3 zr7Tao!1gs%Yn(Xh55}x`kfUA>%bnh;op#GPXaQ+ zy*B~0=S`ms@OG1t-V?8KRkM4GH)xXTkGo8YcO6TL)fY&K*5fqh!%ULv`s<`UP%hkN zs*Z(WE2>El%vB_BKLY1!)PHe=Ve?JZkMJ`x+BOMu7{_)Xc~cs|*ysKrApb(QERd?pl9*GF&LPB!hH1^;uj{8Sn(gH z{+a%ZPki77N;hLUj7$Q7Oe@k0AwhPUB)sE%01bWOmUtgkMeRzE;p8wQiPAQZat zi+Aw`CCVKEh+udcaJrxrb1T#IdSd?HUD|n#U2+-Rf4LA27#Q7uUUYV|PJaF`+uE5z z1#)$u1JIXo{|WyJynA){>A*sUAPUCp5SAFa61v!kh+~28U~VQOhV*4 zK`5II@}Syem!+1~UyV!tHQjQm^t?sC>7Vegi+`fY;|K5wDZKf<)wcX@l=m?+nDA|d zgsXBTyi3zzNdDkXt)p9^-xiY+%^)P59`Fexu7B9qbbGeLtnU{L+BhGz$Lv;;OdYBk zI6vIQ*XTxdR=csnNFkj22ha?++Fj~$u{mwot1gb^o_N+Z8wgP+qe5&NiY6%y$QsB4kPG-LW!$iV1uf5 ztm&X`-GkQ%KQD6=>%`xoI^U2lfs6A~zQs1{hOT;T5)|Gl;^jeCL0&wf2DQcmn`{xu zh)e~2zj?g*eOz%84T-H)&Xp~~)3eFjN-x!?tMeSGr#MRUC9dGtM?`V( zUL^O>`6{x;lDTYI0xq?mZ@cZJsT{?A0W!+N2V0PDHH|uYCWb=_apBVHz?;klOuo$N z{=O7l_u$r*x0j$FgKTjC>=ztEov@BW6QiMWT#|9?XycDFx2@802s*;p4BRcE7<_?R z0_9AB7BeY5F^H5DTfJP?y|noA%9j3wzps8$`;9IUnKCD1sJ0hG_z|(=MOJq_xUwPg z@UT!yrHtf_)YNndqN5zRfG^EaJEr-EfW(@c)ZIyBFNxvrG$yx;37tkCA1J0v51-58 zCjKdiONO}Y;$pScnC>E!Mr2v-L$OXJyt!CQG$0Q9>uXhK{?d@Tc%+5*@7o`lLSzW7 zrJ4AVi(G`7vq)DCBh?>UDmW8ibzHA(Sc}+&ulowJl{!p>N#U4V!1DGKyD0C(e;Y4K zl#HJi(>SMc+LON$<<<@C$}BNb_FSR+E1|2B*ygRMFge#2hFBj$e;4IAZI0c>9{!N6 zBAg!DBmb0IjEvLg<;V`~rNTdF&Y_Y#GUlW%x<3r%Gs)gvoY*T0aZCi~G>AFph~3jd zY}>Iyly+tBnhS%I1qPXKI1DvUl%&D|c!K#O1lH`)3=F)WC}Y}}*6{6}wjo|B6*mTS z_LFACf6}maFN8t$yT~uaA;PvV>|X;VS+o_oettF5#LG9@ABuz9x5$XL8vQy!Pn8skH#(~)_B1C(neEcgpSxAf4N%R!{SD074Fe2$Z?6TZ4|=+QbkqF z(qYchjSq1c%xZ^dqc{6mlc0YZ>du8O+oNiPeB178W_&k7F;U?BHm{?;&5|C|w&?NL zv*9iP@F2(Zo(lQpffkS1n28q_E-U9024wWG)fh*$ry-=xiZe0b?HyIu8M&m4d?}w6 zS+(s2MneSHx=wHD@HlHe^j*gu;xq+lXs!szT^ASZU)3^?exLIG`5lbP69bKTOx{IC+%240Xg0ciIwxs6$vw9*JyD&4`};AK z#j#sli{cE#RUYFQo9`)>>A0`2Y;QBvsM-p*i)@=VD(?7E1@WWzSmNMkHfc^}qc zGQYNs{@^#Vh68lIeV;IC79_t}A4oSK5xUk;+zVXST&evnCj1VHxziotOvu_sU+vnL zsWf$_WlKHaE-(Ap;jmipg%xeCot8ObN=bR56UKw5KMUkcy4;x!=1QY z-lV@h88^G*)0XB85i2Cg(~4oU8;SVmLV7&bI9Eir9YRD;WDUaC8z8-b0!-!st(=im zo*1un@BMfn(t8MLi#&iUelFiKLnPLijRg7j8*lh)$~~BqM2?QXDOoT@V9@t}>|>=V z1W0>0YAQ6Bgl2?}+%lt4*N|Ht+X>W@U2bD%r;OqQ&V)YChXahWaQyF& z=r$P;oBHP=S$87rNR=gknFw{&oMgH~Tb$lS!+@d&_|vlLI*n#;s9|t=r>~}xm*R9E zJLKx$+;qc3kg*6>+ks9?mrpBbK)2@D*u8)fWqL0O)`7-iIeD~>Yc#&zBYpGJBX`kX zo0H3`kv3*Z*C1=yvg=x0(gIFDJe$g8Sa;k8vBk3S(TE}O_wQ7oSGv*BCusjSMP1f! z_UX>?7Ui8Yc2EuE!_;9N6+O*&91@4fSu>slD1x0ofVN%TjlV2D zGb8SQYUvx`1%P9MtPJD%)-#9r$rKlDtgmnySlSwwGCLI8@zdznW)3r_e4vNl#chl;j*=uVxB59nm4 zl!jBQHH_aBp@ehd4cwSBcgU?irNp#T#D1|9AE`%(xTrvPgkl*?ILdoot~@wg{|3JQ zZ@IbEI4_I;AxSRe{-YwqUkO@}m? zBj2YS-tB<&=YVbgZC>B^?fPCQ6Z|g_g8ofA0&&W&I2#U|!q_imfGj2iiaiQK$&Q99 zQ9=}J;m(56Meb;UtkE(fu|Kh2(G0DWLI{C5xJf1;Q3H*KoXR?xC*q#Ww+NlMUs`h4 zyeXgwop_X*?^ZTIE?k+~MRX`%cCY!x04+b=B>6=QT$%EwML>vr5PSGbURLa@DcqV< zpnR7e&d?g6Yt-u=2oD^f6SP?^8;IVZ-H%Od-KeSYruT?e>Zf}jOe=5b?7ge0jCSsr zZnvJn=Pk=81RO%8swEh&`KW8nhS?>Q3%QT|h8^bE;Xs*!=eVUwrBK1h54oIs&%W1dCx$()AQ$o(N5~NLD+6mUdW(z}fDa&*?m04+m z2vmn+11{PQ`>3f0hyC7Sj}bA?Qh)vP><4SP`@aboC4UID5(XR#$(o(5r2~??Tm)6~ zS-Tg?u&bB>Z-r(RBCi=fkUa**A}m{pLk>rTK5o&w6RmX~t7B87PghpXaRoh$;PNBp z62r#xt6JbSzMcXUwv$D@3BL=Ia~g$}?x1!@*qhVO8sa=Y%OlG9Cf4c`oS@<2<-i!s zti50TuhGYu@%e;iUl1}~4dzb66WOM64|+>&k$CWc|L?JNr_4hki?;V zoZn8lpQXm5Ubt6-NQC^ZeeusG>G7fgKYO#pPhY5rG<4SGia#+Ym@@L>V|+wM|C@cZ zTx0z8M!hWrBe6XLu(ua(195{$n$mB?5ryY(*_ddDKq&|_%eUsfpXeVcs^^a-_lSG9 z)_6fc-9Gk9X}s}nQ@FOr2VUQtD`YcYReP1G(kN$l)oy3}c69!B@S>5R!&(`$k_;Wy z(&O~ProE5hAUmbqiA!`ultUmWP>2lrN)hCcM~E>~&q_SudcZl2co)^WYXvNO2P3|s>b15X>k zx*yvx{D53@uvd10qwxse8i-nZni1<~3loiaZ3Kg6_#$F1>nLdAY-6jgVHEGzJLKI? ztvFGowZk)_P^pv@df4k|m%dtO14jtG=u=Xov?0+4;}F{TqN(%0?FRi=?0htOEIFL@ zs$4|@zWJWZnVPF%H8-m@nuM(56S6Y^LY?p^5WM+X%fu}q+o=gYbX)QMcEy8+Tc?V< z9)H#grbXHrT#wz|=}5jFdWH6z*S~G;Vk_1O7nMQm)HfO*ndXxemC8y?<@!%KW}Av$M&`7aO)oE(!A?;REEc^yFXR%D$V{%)@&O{K1QA()8 zr$o;bTJOyRM)kh&nzvVU)dM~sw})j|+_qT}Hbbi3&h4=+Z?(K6vkHU&yLWq{FF_=v ztGWBiq`@|SVzeRB3hCcK0(;&kaz&*}vV1p-c?w?*l8CagqT>T1DI`PF)a{bt?MC8% zzfr!zOk0Vd7paWo*nD&lcMGa^P(8mzEZ*a2GVe9`!6rb}WDc>t`GO&{Dy5`l-?HTI zy-@3Ng#5HX@>Sp0&=ZUTq+hg5)d(rF#1FJZFGp!IiA)dLTrw2m*+%I6uFEgYIgP@v zM{ss&C{^V~x}FjXN5#USj1wEU(<+Ou@6aCN{}xPk?ed_W^dXP_e1+H|?#>>9&VaiC zG>2z6Uf%yN9+eB` zM;jyZ;-JAxB55QgF=Zv$qwr3O-JqP#l}9_*BG%HoEb?r_^sXq?)Ij5)19>j5>D7Xk zmaOdqFRKIVolpJiooTNDD<>zXB+=go>CRTYhc*A)d+!5%ADxv2i%}k3MeroE z3OIb{LxH>51Nq|d<93wPJD)J>m&olPCJE9!8uR25T9{jlJTgVo$Z|K;NT+?<`B2O} zq9R%W@uL|HAFW^35k5%vaQSFOrvltV<#z(7v}s2iVaE|RND0WUu<79yX%h#{23c^n zboU+6)tE*o2Vro`Nw_iM9Qo-o|K>@UlaJj{p8*eCesT#(1M~yTI|25DnY~m}q3jvh z-CC_(O;SB1Bb@5wGNUZ0!UjE>9Q*^$`GtxbUpRp2kD{m%J>e87U&OL(v(V7-Jye*! zSWEaQv~RHhx2!tu-c+~bET0+&QV; zA;1;3F5VKi9zp=ygnEFK*v6jFwSrO01be6CDs6G`8&x5?R8@+UCx{~2VqXh z>DNeF6m6I{?FM?0Q)c##k?t&Yv=P?c%>Ch=Z%4NwsZfW(E`4UCtYhpbVl`70b+S|v zt-*Ffr|8Z^tAOKIS%=4TW{N-sLIX}t@&?bS@70IEP-X} zDQiV1p9q_2vvX4u9@X4YwNPN$zW?olSBGQs#4({P>4?d^6_?X)eLc>?t(It+5Lm71 zq2VBK|MmxRnq$ALC+V~(NqO#kfs?m)r8GP9!!s(fpQCUr7JsTzC)?CrsaHY zs?&1ZyK}+WJM(5kL3w&F+XiDkVvSL}EzXbKmOXd3vLEXiHo_|-msY-QN+LkA^4x=6 zm5~coyTv+zJvEPo)&}F7|Ad4581PI*KhCj5Nz8q5rihQnTFT-tC!gMHbJ~=q5o@ca zp;14Br}1Lv!5w#HrRQcqKZD;4mxH-$;H-yR81AV>c%(n&^cv0>j9#ptMv3_az7f*@ zq=9iXh34ZlCPP#g^Pd0MOJu#P_f0cuNEZkEc@#)j#0z3jBTx_WF{Yx{OTe`}c4VVj8eY$~0q zjxP9)NsQZZ69^pR_iRdaIZEUtnkA3co4o_OB{fJe;Q*>-@P_SYN0fMmcr7V6HN^j(3 zw2()o2MulmiEm?dFu%G1(h@Q;pacpxkpfcM!tDpdG)khU?q||*XbNq1pej~lxDTC`1zPJ2 z>I5;XMVkr}zFdH$c}F=ZU!maKW_s$+j4C++_vAJWrX>@`eBdXQu?7S7JYmeVeuK$3 z_2lC;&X&Gh54!PFLy{>mTP(z8J(`+s?@>~%ym!O03S6=l8JJw(yU?bXJW{=w1m*Mg z4$H_Tv_ef^wiXZ)vk_g)jBxNUb+)#ZD^K+l?~OmTu8g_j0Y>b@_nn!fQ;R3 zXl>y~izj>C>8+4LbHgeXMYg6b;%TmSGrAshQ@hoJS#tc7Ej;`ETa}7A!oBgb7i_0y zP~>w|Kk!uj;kY%?@|hu4;!E96RyZt_4M8V7xvgr;yjgtr?W=Bl`=@661a0%A7xg#$ zD5NR*a3n&c=o~cy=J+=7;yiaVzx<`rYql1y!AN@$(Rmu8{yFPqUDaN@`Y(OfsjGQM zj&QE+=8e31PkmU1mUj-FPGz4CnQ8^{S~@|bR)CXMkCPpCU#Ezi06md1%Sp7wgX_B# zf!FDwUPNl7ZKsufWy#6}V?mV!D`VIEqKf)oo8hs&7Ztt55`q-cHh%NlCl5#MeI7WA z7QJ#2eD;Dn@{kc}O`$&tv>)x`%8~oXuELOfAztRVi~>bNv)gosAjzAKg23<`gSNwwA#8knK|2uuY? zOc?O+?QNs=VoblAinr!02^vh=62ewXC7-+(4$M@4fL{|wud!+pOn(r)xA2$U#e5{m zJT`IE$2O>xjjO+EZv8Y9{UB|8SetE0{0n4_*H5&p<{LGzrv9Z=n~Ow08{4Oetn~|A zoJ|g4S5)dpOLpNSo4#;iSt%ZMj2gi>g87<9ZLUO(5|O2--mAFO_(JU~yh+gfg6*rk zQ+{QPeQdWZ79c6y~B$ZF7oYVNi44CZ0x&S5# zE@M5(quXp(X?gu+K5l~BA@yg8Ptn7KHD0*`;>P<9*Aw*8 z=Xq|VAF?lws}-klaE-fv!5YC7*bSk&Q+!5ImKHlhl*MJGc#h<$?fF(M#h2qQ#3y%r zdsSG5g)AQOQbNLg45yz%IkI%Z@&bUPChL;FN;;e_mX> zI*|rsL*v``UAVPrc=pwq*j!A)-{OOAy-xaO_f3raa3Tx?AG3?5il}AXgy-4<6f9-l5pSUdI}Lfk)bhB!w%= zwP%@-C4|3O0i-Xgbp3*aFuKEg0ze?m6U*NvI{hgY4T+^9kOLH5{NVF)7G2`bP1 z;{LXG@UX;*M)@eZYl@qyc4Z|c5jU7pFT@$+XwqspLGMcjN0~Yo)n-fMBj0EiddSQy zr2I?9s1~m?HH4NDjn%M(w~uG)-Gy0Ck%&mn7+uy`WuS4a1AyndFZsoLBqe)i zc{Cu^H2UUIIqtCrn`tqOGQ9zow8+SV4M}Jk&TzK z7%FL_ETq1S7gkq@OfUH`|N05KA3|OA-Rq7$wwT-$Hx_%h3@}853lca^MN~0bB?;4m zzoVX;_+CO(;u7roLghdXg1Vyk{`d5(OZpbUl9r;g#e9M*nL!GXy?WMtn`El&92 zx;!Myz*nITwg52TVUbbSn-~aoagy{s1jBqAaaz;DrHUKO*NCN)Ej2RNrgTVtkd-TW) zw4A=X-9cIBeGzwq<3o_29%pF{WQO;k3#7Z=V&~A*AUYzh3A?P&C-BHkIdmKPf<{E9 z?q5fq$RGK`?SsvxYNKKNvNI0>hSsr@2J!r7b~Z!z|1|z1{CG`^<_0;{?TkLGK|{~ z)!&{!ml)YkUfvu}UKU(sC|^9}7ncE3kogR;T6373cFlAG$J7^l3mu^f9c9eFbAbfF z0|sk;J^ywvCj7&XR_6f@HYP#JdsZ1Bh{c9;N=0!pnz1V|;)&z3PpVT^k{4OAnBgH^ zxdcD&WccO+bUJ0DaPjSkej2vDP%3ic}tp=CZgkZ z0y0LR3j1N+EOKjJ>I&oIOp`Qg2YInU1h}noVIRbXzD3Uc1g4NIQRL#2k*M{1<{+lzh>+=xN4pkdF~(kCIG9D zjZxjC&-3$<%aIlTb8%6@=4I|&$l4yS(6x#g@ClS9Ac#Y%(rDN>j4d|VQr1NNN{se5 zDPW3zaiA~RZ@nJeQw+HueCorpe8P=J|6ywdCIh%*-u8Gyf+eLdAmY{uu^odr|Ai!G z^uh5E3Bwjc32EKi@YCC%kT;Za z6@eRJU^{02(^2ys(bxphfft0Z8)5eoz7~1M4K$N4bQt4|O!L#)R)ArFX16uOe6He; zG2$kzSCoE8ij5`3XXy5y6oTPRf(3Yh>MQ&py#zuayj^Dlk026SKp(8CnC zL;3M1yw$4&&A!FI1H!F$-vDITM`q2g07DNna&I`QOIOl=8kR_?7Tgb``dw07AhKEbk4RlNJcDBqis;+n+2&jenn&H4-tpyvwv8C}G-w)`^u!UQz`tzt&+3i~4J`nw614$_NrcQg$Hya@op0FX~K8~=;hJu+Xr zn4>VH%085EozvA1E6;aL`=-Y>P&!*g<@<>Kf~q|g<1GK9l5udk#Sc^*`n5JziG@S~aR!*ly$)af!xF%KZKA?hDmUuEuwh4>BgDZ#B-ewV1#7N6fKRdEfL!ixS) z;eyw}>0li34Gr2BVKaY5xQj}>U9shWVA=@Xs_j4hJ^Sr7cSpb}#r-V-kje1mt0a-4 zfMh9Pe#Uz&JTysW=z}dHq-^`}LUeO`c_PHaYpYR!?U%nio5uako^w6jQNIaQ=>Y%H z8QHhp7-IxGZlpvP;(YcX4>2v&?+ma%C@V#N{RaN;VN;POW4rnfdI$s#1Vr$Eu5ZE_ zSis+ekKgl(s3ScoBh)oa!w#~kC|S^A8o|Gr$b(IxkO-j6$v>(RGpc2*@=&9`!vTM> zhXx7$f&MFqJZXkQ)@+E){z(ep%6Xs$e0{yc?IJ29NbZ`0q&U#{ZE2KH-82Ke2#%X|~$**ltx z(?`lpAbB_=+e(Im@)n!$^yctf4V{h(3Zlg~mD-!DolAxg+3d3N6tA+&2vmnu>GaOp zqK(ez;uNv($3&O9U}@l0bN*<71`iwie5ks5`S2bk59>7jjljrmp_C_GCN~K-)&$^+ zuq3XX1t}k(Xt)kOawqoD*p;lqKbd3m*#(=T7RL!|3$sLd zw=(PrVIwF>7KBO&^P=xV9YJL=4-9|*`~7aATL98BO%rBa9bU)q`ob@#%U=lNEv*FJ zgEFALJrAv`4W<#YDEXCXvc4^;SqvD;)osi{+us{nFbJcXmgsW%3+?*}HJf|3hI&v4 z5YK#sU6wK*;}#C`6IA|!B2V!nyiclxWS6Wak-ld(UL}`C;+nbd3`_pSE2n6cwJJaOxfKk`Y`%v6PlK~VvV1e20}>DLs~XVs+ioI4bdOz14{aVz zP`5nM2h8avZq@*r^mV}muyz5x$>a3LX{2C^yM?CCUkxaI63MN=3BOg`Y=m9mjiPPh z>}Cn=MHvse&XjrBd28;!^K6ubSo1P}xzV~dZ}*@7xB9FJtz#P#YULFGE)|YV&Feai zo}o4u>-#z~Ldal5jepSzd?;n(iR9bY;1c8_zNPyVZ}S9t3DkI5g6B&SeOA+)Kj@uT zCweddK%GMsT$6@XkO&HLkCV7FzMYRsZrhO1drH)L*pooD{5~eA>-_OfYHrN-A$E7U zzL}JkRh3!*&Redm2MPkYUwj#PEN^!F@F3qN5*gjDX8O6w8s0gnC}@{ZuBe(UpvcqI zy}W6h4%tsrjg0V? zd2-2v+v>w0p+lnhh$H{#6(sy<4)zjx0gp}_lBP7dXtszzOC%IVV`gqbi~bfE6`5>8 zQefYRZ)8@9_8=$jGb4IJnfXQXVymRnzw2cvE9v1wvDyX2-v)ZQ8^))xM)!I(n1%5V zxgF%c7ZBrDE1J`vX*KqLq9guq0hyMEOW2)(03@lu8Q`oSebY;7YgO9On(!D(n#rON z0hezL+sW-G(=;Q4CDIJZ3!^upUxX&rchu);c(uvh(D2Q-zxXCB$8F9kJa~VXHt($I z$>fHTzX{z;d8czcWZg_=w0wWfTzrE#z<)>X@kWXyR*xw5ibuv%4ZvY#YbYgN$!Td) z1EiS-4M*j*3Ni9)&D@L?_r~4Cf-|V?ha7Nqm~1BiC%1K!{P+VDw_}+6e=)?~8^dQt z|E*!lA2>7n=nvVU8HDafFp=IWqZ%L|F-+W|F+Hlqb)yfhn0<+(?nLhEF!|u@Ae}Hs z+-*^IQ&1SF-}#~mC_31-H&$a%$c;2j0h+>Xl4-%Um+N7@^GkW6_b54CJ^gH*ysTZV zjI8~h#%=9?A|D+2d7QO`eBOioRAx|JD`K^Bfh!oxcW*_tBapPbfeS)AKDn1)N)%d8gW>JI%@ z_OC4a99|yk(rwKyr~u}TD^tQl%VO>>w{%_bona1pw!SrJ;B-@sE~SL+#XQ52^rGSq ztQQe@Ze@dvwt%8>0$uQVwT4PV0Q=kVWmh1lj;Vgt>JUwPgHRam`@9HUXCtqV$ASZH zm!WJr_<$qYv*9V>Wv8fs-1i8| z$o-lCONqWa^fdL^h1IMY&FVI*%eMyid{wGr6!kGV+ge7HVzug4s2*1F!RLy|)c&1Y zcR2T-c$0EbAB{~{b`C)}woQ}!!0F)#4I>tuv5e+$fTf2XGGn+NU^(0L%Q&DYi7`ub z$ZMN=i#T;qM@cd#%Fukp9`m9T3880dVr*cn@7S7mdvR&F3;RO4x$@lnh^r=^WPf~| z2GdW+otu*COwrzos5Q_c1KS=(Wa2B!DCV9C0)}8fb3(ndr>fF`l&~@kZc0i36iV9gH68ZD z8rCaUy?dBgI1bQn#iz_gbOpw6AB^GXG^`AnMmR{05tY=t_1I*~n(KTd;S-<+-wBH` zaLYaatH;CZY|ja>8)0Mj)jzpJZaA?g05Zk0cvW-BHZZ&boDP$;0)gM0l*36?3)EU< z?Z^1WIwgABM&J@0oOflSP4B6~jaOu!lSMO43la!>AQI#kzDd6_4s%BM-}Uw$ETV3u zVoDYni0BNABlUv_6bT1;LVji!C3L$PN|E}L5t1`vt^Q-+E6e;{GOyOSf;e6jYz;Y6 z7`(tHN+0Y7$g3VTn8Sv@B5qa|`K6(a7IGlL=@P=J>ev`O9kKV(KJt7v{Bq_suXvzn ziZP<0ytQ{N38%;NQwO3YV@=9i;yPQUJP zPToWxo2*j%3ZzHKhEjMBrHojdHQ*7i9F*>p3Bo)M(EZDaP#9X<6^_D|nQ0}uA4u@r zcf(3{i;LpEJ&=5F_rvgeaH1-t-^Z8k0Uz?(+TBBdZ{OTzX5xnBw1@n1a#`hL5&g~A z!P$jOoWG6z&i$+0!-1kDVo>@YEX&e&u*Dm!%2&1TW|SkY#4nR;)A}FpEUhn0_Y2b( zq!&{NAm&!|*IU{MNL^-8XDAs%dK~vV60P^?#vvI(EWeB$RZF$YKU*}=n5S(a&aMRe zo2vaSIQ@NnRt092g~m*hgNE$yAVBkV9t^<~Z|S0DpIXi6+Zp0(eHopb*XW2y8?fYfwUyLVwQmTI0ie z+fw+Q_2LQ3%Qu-z)6DhMyZXu$?~HUg0JhTmd%+zag zC;lgQiw_D2i1Gh+M~YcIK$NwtE5h0Q;?1_gCtoaH7Y3@2pMFN z{y9-5c}TGH{+JZ-@27Wd16+&qz~AuGoYRXbjH8jhFA`-t^2CSBo&gLS4}-l zEAn6OA29;JouR*dPlpNvs0hv(&jws&X;Sa{NTPnfnMlh2vljMJSveUJW7ygG?Vs|HxG+3Z1^{aJCr*qhCYzrg-EVYC@ zwQW7UzNYu*`-z7hWe?G#oHN#a=@REBMJbP0gVP{kSmm_Lhv#H1#AM7|{okPmSTk)v zs(X9G|9FqD=Qepx0~nty-E8z)QktF;=>xSK>6-G_f-^Cq>?y2F!Gcd{d61-6*Ttm~ z>^8Kyt2Xjh?T6=D(Cs2nMrK(W(xqCaP}fn~RnE{j2`CF~H1n`Xib8%tFI1Wo3O{J4 zUvvhKnVhaC|LK9nVOr2C*(H``teYkQm>PLeRF~ilFrPVZ0T$e`BpXoH=(b$OxwJKR z77yT+T!+Q{qvJnGqU~*>$r1#T_{X*EX=((QGkYAW^jLs+z zyg5DzP702IDSj-=c5TCd;Fw5^tmT)@BhM_HefLwlQ58K4 z>``lIHvX|+D0%{$UQ9ScXL7kEJtv8J>{HQ@1dh4L8cTpIy%^_?tCF(m`ZF|dG~DWs zP^WtEfGi0TL)e%q8f(d~FE?Li_xwiiYn|zy}{VX9$@8q(pcTjKE|r_0818B7h!| zX$#|mOU&%@58il63YhtBpGy2urUPR9BNPpX3Fv23(xvSV%#hq|5m~Id8ero{4g8Ss z2*xMq7ZLgVN0i7W{wy9M;bghH((=o7lMb!tV+`M`AVhHmeopkUtoayM8#A9Rq&d#F zQ2=W??*J$ZWG)Ug6Wt@|LbV-ic+1Zn-eUb9my1CE?c8A-uGcW1A3{^kEZ5=7p`OJV zAE1{DL;dB83)2f0y*1Ut?vj53T$wDNuKN$X8Mzy>AH;`-hX z2LJtYKMAKVo*<|v0ytR?9Ya7OyAk52&fwSJ@etDQs-3{(vV!FG`B!qazI~Qc3F`gQvUs1E-uj#Zy~ydRMuj# zN8-1m9IZ+qFCZ~}!zY?#*;*PZvT)_)y$q9akR9AB)}CL(4=Bv|Y_>Z0tEok!o< z`@Vyv0h|+cQ?m%CrX2bm7RktOC1G~P9q>3%2_3Oam7$o04$yv{3dOOz5Fo!9|LyBY z#}>je7l5@@K}d}gIv1whfGQ&rCP>Ft!q(@4wG}{&jT6R5Jrp8HD*chA5>jR!btaQk zgNu(BN|tJv|5ZpTXe}Mv2&=3SaQdSUz}CxCq_6l2j)RzTFK&Lq{CA12e}w>#Kmq|J z{!n9-|F^^L&Y}U5G{U@5etOTUI9g9F*ip<{rZ6B-;dmzZ)_7DLmCbiWIvneTVJ#UO z@32a=3bb8TD`Bs~GrjdjqAmrmkw(M0Rw%waxtkCE?^wSq&yGrUhi?WqCp*IjQ<4+7^Mg5h5t{4+cRhf5*-9?2&N54UlQ*#~bR z4LK)E5(BT8GXy7lC`LZ zYKXjI&lmtKo>_=G2~7}=Q+=g|Id0`3x@ZrekIfPt*&=^SS#oV=;NzcPVPjq6;bIbC zo#QCL!4-Q22TPENzRQ=FEKO&o=bX#x6ldpf6BTYY)eo(F z?l5WbW!mj=B_eVO!BSrG)>s#{_Vbph4ajao<$X6ntIp~ZiKm$U;O$YgTFs3LGd7?_ zX;6SETy=`oWyzd4CFc=U6^r#X(;mDoRF(PxVA&;3ZGhs;#WE-|`-}9;EfLZO;GU=uYG*7Ar4FSfIgUV?Aw`Y;Wa5GuHsVqR=9AjHFg@8k z90$7e81-%KuplsU)=m=2&Rv<(XebEb%MQAC1+^Vp=dzSZgrA}KmTN^&t(MBI!V#eJ z?@}#uJrhq6%dBRmtwU_wI>DOCy+PutgQk$lptQCui&{y_q6~MK6ii_wb}Dfu*y`zG zhet@V&y*)jEwOg+IGd$gs;f}F# z*G#wIOV`n(G_mCoYpRM7N@}Fo5*)zGEj294@tf8@+8mDfAsB6GTKUYjD80-OPToNT zdXERXJL0CU|3+n_ZQ@+%7Q0iu52;hTPwA1`BXtUIC^dj!H>8l_f@+Drsg$ZU8V4Gh z@Y`=Ny_{6JoULkig!T@aC8KNdu~GWBd^>BwE(N=|RG)&DtfwkN;TbWj&H!L0_2Yp$ z815g8`M-E9Iey>xfK|IhCoLpOd-*vQekQgkETn}h+%f|yObdgb;ESHV-)R4$HF|!L z?abS%eN5K%m7q@UFHZS5dKDSYnw(7$DQuYV0Tgc~{!s-9(7eJ>h@qPh{Zs4PxqU(W z%G=F(O$ibK6;~zod5pA|lOO=hnO{vXpf^VHm)We9X1}po4R%Cj~cCO3fB<7HEI@_HJ-pbw2&3n)1_&Tx6hI z=Et8fS-L@&VO!#5%2ZSR`H*UIVgh^GkkdUnW>;HFZ1wipC>0($$+Ccw;m3d~d3rCX z43j_}+>FqjwkCLmjA5)Vc_bxE!c-S??St#?pdH9};!z#9KxY9kY6Ih`YLE5l7FfJo z8(3|Kp(bR@mxpiy(Q4*<=Hrjeh>BYyM00YB+LEdk0Oi;NER@bZ$xjTzmLJ0AewqQp zqqpW*bv~Jo{mW-$sUv{k;`9Q*^f_)N2!r1nDB2ZX%56*X9vh-B=8_VkFXGY?(j9U- zfbYdO$A|AlLcE!4g|~-KXMVoU%0RT`drC0|oZqgbK$B|*JbwyuE`AUvW(J0ih)Y+{ z9XXr%2d8ijeKQC(D%=;c24xlw(+QYBSn@Xh&NC=yS zqN%m1AVGpYhe;6$wm!Qzm*lbuBoN(9e?~Y4ZfQZUFHW*YN7iYkQOld%86e5)2rdW3 zybm%>5wB54?3c!A#yVqCq$#d`#~QO_);L+Pw5?-u(-hX#^t{dYyxoPGddj=JmT_@4 z1t(m)wY_0^V+TO?&S1!Q74+yCdgkxjc27j=aSOYQ+1KI?Nj-4KNSRb~P0B$h> zcT;q6k6*NJALkovi>dZz(2wy=?eohV0OtM%BElU6-;BXHT~{G?0?yYk1{)D2J#omK z7Odp-i`R5CUYsM~TVuViGCR}7o4P{H6(ta(^9)o8ml+-5GpC~Y=@+ACg$)4yEy$hVy`)n(Q@C2_G3EQ7mN{zYvV?nJ#Bk{x$xK2 zhocW*R=KE?JH}V_GRQv*U4Y~Y>*L8UaEs8?m;u?G<=MH;w)gAcmsn&PHx~+|Cv_=b zFM2_|y#VK5Tk?2jE+pwvwP`+9ZcEL|aDRHSDchq8$%T<@i`$}nj8O9XE1T?#tI^_$ ztEFGci~|(y)06T5UNc$gYeFnI1kDc?0J|x^OGm~_PD-JHLg(%UE`q~{rjQcekihFb zDp?+;vA8**?#aTc&)zRViTuCS@4H3m9s+wGD4E;dWAM9bpPlCVVNLOXJT+- z(u|S@<}^D+MG$)+&bu?1QODif1iw8GVQ9sqrFX^KLbcT;%wQ^Rd=fOgLXx1yQE};a zBCYI*cC;`J^r~|(^RYf&1yx1VUu2YO{m-BN$-SC=7ug;;&h=l z2_;*jY#o2)F4&2K>UMJI*vsK&KZ`a_Eo~j2CXebT0~P?l2>`21u-MVZU^j+&TPs#Ex$)8H zWSh(Trp@_ydrJTS_=0c{!HJY8i&?287*n@M^R_T<9z!~@3@3-%a#($z8xJ-#o2 z)!w>s2wn=lxJxjqnG&0@c88MU0QhX2XKVMXeL$6t&0PPE9u@5J1Gud1dC$`@C`ABx9#8XXBe^JYfTih?x5qxhgeo)8eCz z);kTUpz!Gq5|PYQ9-CALww5@%dMnU}kUXlpWz;W2Y??;$5nxTR@XIY3$#mL+=_;k{ z{10(;l8!f+xEPGkY*c9y%fHHwL95%4cQ*^rG^RqHP?ZaQB&DjDCvx)CRKK&fYkNBs z6oT?qJpP0vR!ql1ak5jKV2%i+4+|8(QXJX@@eVZzdIe|Kr3|SXw*2n|ZqIX=QT9im zI)P58wG{#AXyE+lQu543hN(_rg=l$bv5P^4gD^!+QgeyQIB*zp;8r%5q*(p4rsUJI zjQ^l^^d{U5Hd2&yWi)a=@xI_)bEG6u$mbKuv@)Havwh#5w=Hhc1irtn5P)t9i1v{A z(eJ{7A|xM8)r(WMpQm1QA z>?_XAzBD>*k&f<-TGissHlr>O>+&Ljvb{_)zF2$4vujsR$J-N=Sk#JAoT>&0fKF??DUh=gs9}tJu_UQ17`B{-(TChdm=k@wsN!%Fv96wBzJ^GS2b`3zO zvPWM3u-UXTOe(l;!Og5Q3Ja?t73nT2(mf92t}2p(FpBG|@K1gyG9W*EH9={(8&Yl< zI6J&_v{X37C|BJ5UvuP*pd1tyx-FI~?){N>FpS>ac&M>|^=jKO^50MFv}b8|$)YKz zuJUuYk8$S7PV3BB-YDLCb&B;FQ!c>3>zGH<3)%%3I@_gBA;SPk;S!rl3duK$sDc1v z;d|ZHoQ-$udJd@^yq8_;LHs>pq)W4hdV|MAl#4SaMALF=VcT-iEE9jAKK+#H_)hS8 z?bgesh?ny@{oH4@O-p;}j}B7THiCGb6b_s7=BnI-b!Yv;B^O-D-Lq&K69R}Id-OR8 zChmIer}Y)o1^Gtl^H^DUM^}PcFXKScdv$`EF(=S7 zoD=t}C*mEt2sJT^(abom{+8&7Nbc1QYR#m7h(wb}G$M44LH@K~v)`00A3Sm)#1Q1B z?e1vf?&!?<9+cz4v%XpMPVe`MzjJb!bKo?Hqy0V!2cTtm{*XqBB0G?*e$-AHrtTF+ zmJ^bXr=VGR;z1^(J9!D!zT;!O=fAu4QxdrQ1UpAnz)Dg;XaHktZPAAdc5z&qC^O5s zO&fLVEwB$Ilave^U4Yi+VfN<77RvqC-8;lC>=ZKeDznjs*p^seFV{&x317f_9zcPh z><1F_`c!p9a*GKD`zym`4yKj^D$`cYVH z1nD6F5bB-699@QqGx`_0AvNsroXZe_DkWcLcbHbn(z2{a7qKzLNpq_xjOQne`H|ox z7@l6%lS}qO$HKL&kpAUocr;lByXz6W`coN`T*dr*dJ!k3i5?4V1+&H0DVAqf$JO*5 zYFkb%-&W&qqhAg6;FDPclV#y>HjWE!EByb%?NES8!eRjf0a^U8J5&jCv3Lo*^9Tu? z&ZvMIIVwpdR1j#W?Uu84TPcqEt;8>OgfAdKVyj#pSb{J{a+YVq&Cd}-CodN`y?oPn zE_j*9nWQQ()o^(`FeSEq{}QS8dN8ZkIFE#&yzvw~jX`Rlk+@?ff&kcQWo>~h0XR~5 zsbg#un@dm#(we4?s>GNOj<_Dr$l2aT#eV?|tQm!y3d(0aOnfH^O6(a-Y*0n(Z4DQA zh3wtcS;HZyVjCK0#$n2qr@h`3j}VSM*1VNj%85tZdkV}=?-*olb6!xoJJp&&6Iat2 zH9^+^nEolZ{Xq1$NScN<)eJ#PL)UaVVHCQItF=nAJ9bDg5Vkg}K20q<#M9V5>yTi8?W4*ugz~!apV`o9G~k zg^G|Zf|7peM7u<8-x-=RdkWVLBhHj*1W(Rr(7jUET*pEZ!;JMb`Z!)J@mw zWr`ERrn8r_w|=|#@ZA#tzTa{Fq}m?U!meSq2o z4AR|s$P8~lZ3a`D2#NDYPoI|s#Ih1~krd{8&<^L0I7m?LMD-z-lQ}Ma(hq7~3E}FZbY;*~2Z-9v9>4a6}AHcnT|B z8JkJ5WS#vY!(T_N#b#S{N+Zv}b|t|AI5I2Ej(h63_en|yZ>ea_N;fBK{2i;b&RCQV zy)f^t7tC6fW)joIPb&-#?k(2mXuwhmK;Y@R)co4uLy)ItzxhTVF zYH}JfG}jUSJ)`PUkP8LjGVh)RXec#Z<)9(!B9 zOK3aah`yJvt#677&Qm8NuH_%t1^pZ zR_)Vx)$ZdUcH4=dRtxcQ+KWzh*HxaZ+;=mW)a3RiNXzcB}8n`R~e7%Qlug4uYBN+Q! z7k;C*&qm-M*Jo%3haK@2F==;-M!?bq$L-#vee$LqXJ|O2zJ@|eFoLzt%j1&WnTF`2BPbI;HgnY|E;@nKC`cB&f)lb6Y;?gPxf0Nl zJT6br+?POkGadDwRHu9@>sQjS)$`6?>av{%I;Tq@og=Z07UI@I2d#6P1#?AsvtbOc zZ$YFuw-(3V&E?@cUemd_0+nXQa`gMxSzPMR^bRWZUz{lcT6Od$1GFoL&V>yVtFL(b zYuDXow&_#k91-sQlxNUm_%rNJh+R?5UDadE&5Jfbz*IsKOdbG2CZpN`# z_(s_^NXJ@Vdc$106rRTpaXrEe;Nvx(P(uz8ZYR1RnIqr~O)7o*ZXx9?efsjvr$wpM zdzZv-+OBSZei}Cp=SU~{?hsthAjyvyIMEE~#g2`dm!f08)fNKn*EGBH6Q_s6_`Jj8 zw9P5>F}&M&nGad)d%>}^QItaAY5RpV>|T-&k6~YY@DheKPG9YWGtg>QJiWHN+HEHn zicN3RdFOmjG$|;G&erC8Yh+_{@D0l4>QedYUStM#f`p|y5H;zyRpsp=9UP0ivG)U? zqny~AI1rV80LmAf56}*%g^OaEYbj_{nT&qMgPaC$w-+A^_l6Fxo-dydc*0;Yq^VHWd1SP?fKbVLA%xgVB zsMJ)@O$%}{9qaKm*p@m+7?&<=ziR%H>C@Fk{IuU5@$cpRv;E)?Iyf}pcdGhh34=NF z4NiVi?5f=i(0rSE!Oepfy1l^O40=;>-*f(9l{@obQ;N@F9TxM{@+a z7%4eVQdqh6WG1r#+285q)libmjq9DhXjF3jUTYcJ2|MF)os~z#SPP0xx}Cc7CzqaB z>~FitQMd}aSopqNTlEwr(!w$rW=q{&b|@IPvB0nw7WaEu0})z7dtMkvGLZ~{u>RW9 zOrco56Qk4#)HSKm%o)Jh_)q}9%!FN$ia!#_Dl7eJfC-E{0)?GUdn?Zae$ix6qY-TI4t}18A9c(ixtOK`Y>*dL*mpCXJ^?A5a zW%0(hhrYr`*ScbA5xwmB+zAa@MT(G-;$_ky)|Qd{9V{)fYy@cu9B+U{PU2knX)1CN zg|k@6no24y=Wp9G$zG7Ca+16*0sGEOA)e&vH0=!c=3*rcn)MqM*z9vO{i#mQr!S53 zehMKmjL##TQ@&mwe*W1|=YgRUoT&@;b-9IYIaTOj{sA=DzwUcqlHM5WR(8kS&7s`S zVEV~Izs9&FC7wBBBK`n|=1hsP2GH84#6SN)c!ZJ%%RKGzkTK0-Uw;J z#}yNey3MsV{Blg&xa#Y`g-(z>-_c;d!fo9@`E$voy#~|RUr7OYF z*4KtJ*hYCu3DOGH5>xPqt|zNv`LED$XcFF}`Xppc7^>9)h(1>(W;b;RysDH7LwI%E zy5}#`9~e;zO36oBvVA0Zz;eBSwCMjJy=KAA2@yx_VZaC0OG};znSR6Or#< zP+b@&^wg~30AmPkj50Foc@FJ3Egb0&Y(fSdu&JA~V=3#hnmXwvz=C#Th?KVWGcG1? zC|NP*VzUFhW4#BZ>GLD#UUp*cXWq8kZ$8V&Uo;@xy_b@48Stfs)F2ou@%B;^#*S*y z60^YHs99uj&7cCC_kKsTfA+>zF|8l5Fhop}gmn7oO%xEMhm!>#iN3SkG@bN3cx7&{ z8mTZ3yP|R2YYxi=x0yT<4qQ4m=NR3+ZV7R4dTIc$ireKMYagh|-k5w&moC2ZjP|Qu zYtGz;if|_BF23XmJ(4;$o{0$jsihx>CWV{7YcHwd-*ZR#NhW}-5TsNrC(mpV!tH&8 ztW#9M10V54!_}8(N6u$c|AMTEZ(LLEgCR;IPn>dv#X+`<7Cb5|6Yx~R9+GW_GbO@{ z5KREeVwr*)kPeWla!)CJB;MQd0OHc-t1o9jwOAlBb}R6;h`>cI(WHM5A6hZp)_0jSW? zU|4H1E8kpS9Mc&W3kw{Ay{j- za`2gEJdEDbY_v?M$r{@Pefq_wrWPe#Zm83)H0GjR+*#)SN)GS0R1SH@{co;?1&=rE z5PlOp{TM+`r3=EI3*S0{dMDCRm%S|&R>B_`0E@6AP3_qr&vSO{%}kQsXlag>-*ssc z3u8V&t&$#!i+kNG3)6;{IS?9P)H&f8LgYz|HFVqo-Fyc=k^&vRY82`rRl1AcZJhcN z>9XQsnHm#CKjv{=N(PR>NUrHSfhfS;Td!ud$U30!tGt|^^Ci4sR+xr9M!iRDKP^TM z7`{MRfb9$XH<^NOX_j%o15PbXY@D`NeiFwX!#V5l0=}1WK9Iyw@v95bNrYZNo$iV; zuK?X#!LRR3-?QYRF63;GI4u;L;&B+h64TYgI0(MbT^B#-%15!oWfpEmOIn zIG*-F&tscMzf8Re-hGW`|MLODZ1rGe_Y>i@XHYJ4kcRj%7kYsw5bPmFH@XiwtkaDp8+f-Koc3x zfJE!+<2wh$tVCBcLK&t)E#ITvke~kz);r2fpYJOuD9n85BgK66+oA&Gl?eT?6?Q0+ zzGr((pYdtqBhNJI=U6`r*bC#M6G2}7Y8-ByKGBZndcHS)=BatuIb3ADT4Ws?=_)s~ zZQynUSIC!Hie^N2FqAB^9aDNfAPxcek}wzy&o}W<${1T2kyNYQvz50>a?x3W&MjBH z$6)|Hi;)Rq9(sUD=A6aCWo%9+lK#Nt#9Pu_o5d;*85Dn^x(3gzQdNUocaD zi0T{(_m2m?1rh!LOsiNc<5P|lzmMD}NBlp6|5m~dQ(#z&xPR1}6gd= zMJjr~k(Chn+}|`cF(Mkps>-U+=nULs3?lszlLk#ujO8rf{rq9J&DZSQQigl>0lPyf z>92-9&k|TC^f-~=LxYIdlUaPu=F^v#F9ZhNAeOs>f1>04Be|75@}o<*;ni9z3(5rLQ+fKzz9Z3!q5%*TgZCICTn-E4|ivU6_rru7M_YUQc&0mYNtclT2Hj-6l zQFi&vUfYWVp&ZIx{rk#oBuS?Iy>uuNwX(Pa#-lrh%ynUOc&y_o-yh5XlN=c1RqdkC zJ;TpNO!k8%&WFpoi#OpVi=P8C>&UIC>S0(1zPP>8gXIa(%;qeTgNlzuITGyAwbp?O z%X9L4i6um$ryHTItv1PbGC^zoK^CiLArB6b@)As8n%>!vueM?&uci}oGU-yGq4UgQ zm+Ki>L5t1BE0;XYC(AqSPz$8NH^~$V@MSj#d`a(XN{{7rAGnYoL7kSg);S8#AA7{T zo>PXMAAWk}^R3iFa4kCZDfJ5aY2b)2Wsgukuk2pGAV5&yDF1L)sYR5yZXPv{hamdf zq{4rfm;i?5_sB0GppYLlitqm_EFcUYVB>*$gyz>{nxI7v8vGZ?U%ezf*3=mRIAXSu zD8DdR5Jxt$aoxyW7J`FiBW(@ra!6X|YD<5XUERA5a^1^c8d3R1k1Iy z{}l>A;ps^knREYQgcY-EA^Y(Ffc^*aBJ)aC;i=kR^JBhJto(t9kN?=<9ES!a1g)j~ z&9keqI(JhLcE;U8vb<`zj9W(1iJ*8~*6g>usv90=W!bh!efOJkbLk+@0E7s()d=mL zPt?Biis~U{NT3_usSY+^h80aZvT39^gtz*lKwuKhDsL{+da=9e$`rl=l9PNxnjEXx zjdxQbg$wQmRMIDZ&es@+tDvcd9<^Kk^JSba&qdcKg* zRi>L&@3FoeH-E2*Ey*cBR;FsK6Btu>7;OipyijwhFM=U4@+&3++Y&hY~l5)6Qj&cF2XoC$?3#3gu?}WkB zseN2f4bamj@_o)8;{6ZM`n?g*ej|OS>lxCm5KGqL?FI6H)8=l|H-^0zlq^&wHg?=8n%953xS6PU>1eqM_=oiM>E;Bt2C6bZGzC{Zs4T0b4XqD&IxkMrwTqeYW$ThW!+VlI>}{J*Zg0w|7VYa0meu8X_7yIX(|T!XtiK^BK# z0hZvd!6mr6y9d|c76>l?zPVrh_wr8FR&AYqI6Xac=$Y<*&T|}DFJy>M^t9ct?Qpz4 zmm;=1G706TQ=WYd(iQ3m5UdC@%!C(~S^E;g9q;}1pe6~~k{9pZ9ILmp zxT~9*kMi}>u=E{co%VarjI^5x9-E)SiVNQeAP~J=whDh65)&c~TOSr*H$VYnSzqe* zVql_#yVN^ko$(G=+qhskVoiAPqwC@y!k%(HOliI!ihRRat;UE(zqZjvlx}m`@4oefzms`!)+Z$?R>OlNn zn>7`VRr2)n!QjTE_eV5^{;u7?Zl`#Q7E{RQJRE)%QjZF^A3ADX*e?1Bo(Lo}Lj=6b znhgAAPW_b=m#+a6>w2ldWVm{yKL$8!s@gMTXG(bCzxgM4%&4rpNO={I(e-WGQE zJkHo{`B|Uk+@F6AQmk&RO$f*N`Xj^=PvD;FlthZ&IH1UC2U{V}1h#M*$R84B`GF_& zx793mfz=gu&-Bq0wx(1W0%P_i)wYalAxIseNQO&%o=8xWLWV?z7a(RE;Tl7U zc0^m`#)NcJ+|Kh0T+Ln~Nnb;_P(%eQl*4;GA5IcF=WHc6e;$6}<>GK;JF=erVIOefj39Ejl)_<|Lr? zhoPgxsPyVXziXC2KOXjXEFo%G1p%SL9IHEptg#__%Qi3iDAA0qP9U{?fmZ-PUK!W? zWJrF_T<5^2q9tch^Wp`-^PtQ7=iZJqZ`z%Z^d?|3RJp*>-&_Dn3n#XVX${LK-Qp5M zQ%|~UKFFfV_JJt9Sk?9D8F+7E!{D@X^dKABL|CwU&7M# zr+aC)g@U&2^)Zt#vb^A4G0s0Sy`1s4MXdreTs%^0^RRD?upIiu6P`(6wm1#LUV|C> z2jj1TyWFPn?{rpSRS`;Mnc-`cYN=37>S{`w1=J0A-Z@){Tc(g+)}37*a_E$7qR%&8 zLUe*}?uXz| z%*xQKt+G)S_-ZW}HYoA?f3uGMS#0D^KNKQ=7rvU~Rh`AbKV_OWM=g2B%b|q3ANTNCA zSDC!eT&(+>%%2m2gTub7(We>#;-a1}W57&FvXrJDmtrUC=Z5WHc1KZ^VjpC?UIJx1 ztpKM_QIcB)f^Wp&*9mkI5xV^*ewEORe`m8$xk?}Y5M9>M3qVoqs`1=Y>j0cos_Hu z2}xGscJ5+s0XK`!A=C@Z&iD+|92@h>&7Nqpgk8kpc5WQC&Ve4zf zyUa2NTKi@RWwMnv+_rgFe+1R31m^J#S-lVDs zDVcn2oWS}apr2gJT3b1o3Wl7YT!4V$RK%M6GtKW4h+N+-+MdUPupxjM3C*M@zR`JZ zEgGM^Wqt?0ay+z3g0{?}bu`RWAq22=7s*|!gWC&n_ao45n+_#5h?DQw*-8;=LNZc? zfGqVj(24k6Xo*1LxqXug3Ck%FkJB&&E%mno^kF0gOQXuSvlAqfld|wxUrEwrC?#WxfK~F*>W%7m)z1xPhP*NwORLqR zhC79BFd#!|ok>nv?so)IR(#Y}824C=#?Zg{KXeb6nlP##3Fq z#@1hF3twd~5uSF?l%!&)L;}E>DSAn|GilP>*%K3Esjfwyzs_Es{yf74c$d@0^`W2$ z)c&@X$j?qEq+u^4hm-8DgT>)wu;8X*D>gBdjwjb~O;-Y*%_%0CazrQVPt$QhSFKp& zTIhui>U&xEU7=ZgBmX7Z*mj~%>V{WoKu7g?+=c0trMI`l$*ak?vM0x`#odO7`ki2f z#XPo(*L=~?hv5q6)}B!6XR_}@9t0d;4Ih$@?b?1(d|-TBEpF>L2vqc-;W^k zF8}%Xe9$8(vRQ|(7P+8rSwQo@vV;Hd)Ya(%zrL>&1mt7XYH$vb?S zUM)ev4a2pjJz^9OCc8xfSnu|i%@|g%&=uw@)=~At!Xwq<)GOWb>$Z~~fWhh)EPd(b z0@jz&Z^DN^?QXwkX4wh>ile8AJ{EggLif7#2fFF92utUEu=*NiRl7gLh(V#iWB>j< znBatw0x088I;|qk{5|P66I#qTS-%ClL=E!Z_plS`oM$R-iau|Ie8?-rdrTb8qE4~< zsjVpSN{yN*+J&tzTO5@ivbGoopt6LK>r|z8IP;{q#ghCaPl!3-K6n<=a?W`_34er> zJAtjdsmU*qhi!=9(>t1B_Hc^|bbwx5;ofNyTi_$xqw@PS!H6L+3aF(2%?OgrLPw~D z*>0D{J-l;#JO84RF8|mJ4&n-M5cB?>f4wB(18r4iKj8Z-FHWlp;P2#(Aw{TZ8YMyo zeUNgJ`C$eLBES?)njTB2q_&rRB6vaXxPytN3Y&bP+xA(2rA@~MRqVG5o$s|J-z?65 z&4y6$Vx)}WCkTFG5Aa4n;*eq*DbP!(W-^wRiM*mOSJ2+MNXA^Cwv(G8c;I0kL~Ovv z1j4vyi!-%`)AW9rcNtsLyz06ZA%x?RuhnRDSb7YzbQZyk?6w@W96BL}OFB+m`&F|= z5O~Z^hcn3DUTc5XAs?|(rjdosaERc-!&G&&vN{HpDsejTeAL(ZtAgz z8C612m?T$osCcDF5qb6!cJYF`#ddaE6ZrEe-r2;eVT-FycT-1M63rbtmEMi+7BzP4 zbB?FtTx5t6Y~5vzIsIW9b5F8#*ptB&@-uhY7QT4YV#PyMh;E>{Eikg3;8PT{3-jV? zk{0iVPTT#C!4_VVEeruNqgN<1LKVTn+0tEgQYIzWx?cD5KD8a=Qa4drd-|!-5-=7; zhsXEF_Fd7AUZA^)$6!{pI9-ZZsxd>Q4ItT+?)mom)xDKd1* z0EIBmGY3BKE5KQ2n@hmDIW9vu9C#4}4@r4_vLoRUs+#u=J%D``?vtdAs@c=G4C+Ju zOhL(QsBK!0*j>+S1BvC#b4>dPJUQo%PCoK1agp{|o(O1Kc8aM?=v-oV>T7`X-rPGO zJImTgSt;kyVy|fV+RWT2B@Ai{54u)bRS(+&@=m$Q2?G2Ofq(prtgWasu#~!v3;}%-7gZ^6+!L^{o#+9;EYQM$mo@%vc_7FGUtr5Z&OocDVR=Z&plk-UM(~^KJ9dPm%nV5y5zY zUf*)MxnDBKwsvH8+6!!13*jthSAPjtiqX9k^XxCIQ>5?M0W2Z7 zrid>{hHhCsXs}@$f}dhSkt`_p?u5zsBOqC0Iwgu`>d!qaC&{6vmK0jTEifTw>=rk` z?tA&b;GceEXMr(dEyVPy1y>ydM3O-@wXHuF$E{kh%2yhE1??|B*^zVQJ?(45Y9ucfDo zUkD!hRM$3Zqb^Ovk*}vzutDQWm-&pxEQ)s(E#wnl3@v185&Wkm@s+LYMenGGXdtfQ zb4Zay)sCJcD_-i)_Mc4?aQ$!rjphBYn$B3?H;^5tg(Xz|>ZIsxL?y@1 zkNyU2b$e5TJ`7`u5wXVj0xtORzv5$=kWCCrth~xi2^f*F)(W%FSCl311hR}Y22tL# zS4YMkr_5X==DN@yArFAbV}d?;BtVX_3{lR{qtg5i`RT-ms1j%4-GYLv=J?01&4stu zBi^mdtFY6jo;kEQ) zq>NW2G2TQf@|RcW>PPD88kU!&qUU|YM?0f~cxX5g4jQb_e=VH9r?y>W-a4TF` ztx3esVZUZW(Fz(#MvlvFq9n-ni}gmQ1ztPk1~kR@ z*smTM%WSu#T3e=o`H}2n49t3c1uY5qYiGjx(uBO$q45v@O8&%uw%-MU!&U=K?fcJb zBI*(Vn5jOgf(0(2YSChTsGIXU->5!9JGXG?qZm9rc>)cM5+Qqc+<`qEh{LQ^(W9{s z2&23%v=oIW$HkuYoFDDjfb%Vno5W-N>;1aVMw|DKPn~{G$T=bINC;zk)z>Bzjwanf zfzs_lJT(Q4^2;u`)ssf*z&6QYwvTF+zUww5a<7GaPD7l!S4TZR z@j7Na7Og{M8I0u4vqDB(ZzVo8$3!j)VRXO?XOru=8yso@FrX8h(zz!cmry9>Qx}DyGi5Ys$m-3{`S%g1RCu{3f*+j#;jFN0FPs3+Yrh>b*F|illzW zDj~YLE+^T~%SXazJcYG4ZAqV!Clfnb-Bg;Xi4&zTCFRRa#{00siv9RCrO3)0$kxO{ zkJN8ayPrki4V-DdAQTXMT=eF&d)gm+0w$HuGKF2HxAe2EywX^)01kP2@(&89oW`%C zx;ccoqke7U3cr7+&zn?kTx*xW#Sy`(9|R-|H4RySJl&-)kW=x1Xo_(@*vh+c3*MlV z@i!{=3QW1gExJUNvGcjqvRxa>>P};uz}P=MY1W)X=UvV=YBNcWz(Ww8>R=F30s;{) zRFSXP&F=Yr-9i8M#iSMq)yVn+U*R6G8#JN;!IWO5ze}O!|R^ZRut7FkGPj<8sy^%}=V~)(TVJMwT^5efF3gNDFJxuy72K zaM}2bUi@KXuzaCWVx4t@9LkJ64(SJ}UxIIkS7scmAUJTGwu1M1IQ~8#8;w9ONp~but4)^73*zD)N@^721(>m0S9FWS9my< zCh}}{2t1hPn7QCnwX&}B7DLqAoh8@=yQiqjJ%kTQ45J?_ALt%Ld=%D<$eyrrh3r)_ zEZlyDhOE|n$SwOEAF-7BN>*4V&;c)*jHb6H7LyT=DY@Ye+p+H!bg4a*TN$^jQ9QE} z(xdHr*lJgDaGdM$lnxLzGA0o?)`whGz`SkoZxp zNJsO4vR>>OIY!^wlu2OsD-LPbn5=53BXe=mT zM$xA2|2w3je|(KD$u8IjKg!Cfh}Fp)BS>eflkwfNHrqA47lP-Ki}r7sxb-j4PhoTL`L+(FHPD4UNlhD<%yTRHW|y#?HRx&gRO>!7LVz*=cl2 zHYcDLDKYM^i%1i6ieI3b0L!mu0YldUpG}6N9YBq@1}UZINAm?qrel z10;b@7@ngotu#=`NAFe^*36@lM?a?;oAf)!WNiJ;E8Is+O4ajFpmxC1daqZtEpxPa zg-m`%h&=qw@(E^AXO+jrHp_Mymbf1&Tsv$le@sNBCN`Ta9%h!OT^d%{FQbNE+^64i zfuLtpIX?0jm%i-VOwpOhuk<7q!(HsA`UwM|+DWj{l8;S-+&h(%{f4U<|E9gv*yrR7 z=0qpVl&lT&gLOYcLzfr@`9|T7yqQ09s!i3xjw_B<))V4gz4WKrqCM~5yn&o}RCsVk%x*Fk-x)YKf;PM^GF0nvd97TH2d(IaOjz{QFKN;S~AdwrK z;)C~Pm_`tm5I|}cvlj(@`g=yz_YrxP&MZv=iiT$LTZ{ER6cjxj_iZ+RxHirCzIH>T zBE{7u-s+&p(TYKH9)8&U6e-7N%liTXN~rhV{fhdiE^XL%vZ00EC-@VvhOmS_rpG+sVxiq}L2w|GoSvU)qKmCga6Ixh@ zXNd^`F#xVMO#^Z-W&|R6sn6FNGG6h4K<}vHA%cY{(1uMDAS41VIHc4Qq1oWW!oB2_ z69R|dr=&ute$Sg@_@i2uXXK|wdcYgY3U2nz5(P!net=?hh zO={}cdsLd6ot+b5pQ`qS<>Slqde8NzyJjpYt|*mXGpG;1`zN5Z2x}e4f&D}Et}x|$A&H%M0i4V4LKFKz zLM*$SyPUc^6q%?O_@rOwt%bhME>+D^k(}>v?%hJ)zsHW07cl4r`ROxJS`J%AM!)}a z$z?S>>*w1r!084=WQ*Fxj8SwiHMwz4t=s#JFcgV+uZ|eZuqCJ_@K>1)$b(c**doxNJ6$gUugXX{Zef*4fHHF4mm|1N5<;jZV(i#pDdj7# zP!*y;!7Az#H#|$PnDXnJ9l)-C#!fX7sF)_^1RSy@xkKniwS&kU_2ktX4(KEKm0X}& z5kOPhyY+=Lu?GRjK29)0I16iz*5p;;gv0@RW*9gXNwuTJbe_`@5MqHf-m@$x(J+dQ z%HM50R9zRL{wTh{`X0s70n2$YwVfTVQNZ3%3kl2HkpFstlUJCAv$RDJ6u-BN5?+vy z2-M-XTQ>G~{VfIy6^I#+U50|TyHl4S9-RSYs1>bA-mVu;G^T8~YQcr**&?ZluO#lu zW9RGgK%GO4!vsYbtlh^kT`?VL+RdLz85I`(>2tBsL?QbR6y@t;pQ45zSQrkLIG7s| zW@Og~a&`TYG@v08oO%eyiV>XQ_$RJV5mFLlj56 zcN$@mMto%xbWVQQkXVK!@e$vUnX|tOJ{KitVwg)7dU@c&yz!YuTAIE1;6-LqeeP@= zv)gjmEc;H==d-Cn(2l@ugB&_W!*?osKLaZ4+O%(jnG(y8;Uzv5B+PcZ~a^Bd%dTTs}-EnAM1B?^64ZeJo|d7wHx6 z4|T*iIF*oajCPYwSM5cJ{Yuc6_YsJX?}oQ#KPBEWo5MO~54$Gr$q}RS+i)J|{3Nos zDCwth)D)k?6k5}0PH!6)>E{n3w-D%XJYKm3wu3az+kyvlNS@YhE1M1-Gdbah>_ywT zvBBae@dJdP%r8jR8IeUPv&<!-Z}D$GSRxF$0gt9Z6NQUY*_9d&ej*fDIr-#%L^a zu;cE#h~P-XV9`llu(A|ASNjpJ4(|^B_wP$y_uJ!pup2#a$Q|E0jV|n;q=Oyz1tF}c z6-^5fBKyCY!)^@2g&wd3pZ}7Cu;*r-hi>YA`>;c==B}iWStd;747+Ia}~ zhdRO&OuAD=^Ns?mliI%{b8E9+bu=hA)dJ;508x|j7xzw@>_HU&pw6sSOQ^Ztm5L;F zrwJmOs}seHZk69^1bQVgGUE>X99;f{ff#f=2g%or!CxaGv$rf!L8qdw;B-hm8*sF*|7Q?_j48vT42MO_FKTiPt6#t22qgN)vHJ1vVKYJ zw7zJhBC~Y#&yUz@?zA5gMMS+V{xsqY`^l}n77&d9T&v{Nlp~N*|;aRxYcZKm)#j&F;o2_snaUHPV3>8 znyZZ~K4UX& zy{P3H$^hVACgQ5YAq((9p6gl=&SLNESTq(Y%$BDGj_ozGs zMc>+1DjdwS;@^S(6lo7qLLzkD&DlogU5Y;mJjV1#RRW1cZWxEVkiZp1kRgu`S?RGd zIDE*Kger0?@#_dr&6EHIz0qrZk6l43g|-$S1oJ5ME6a{JEW$N)FS9Q_L$NER!Q7(& zRbqw#nawgBhXxKiGuEm`JZ85|{p?*{hM4rHarl%Oll~ypGHQ1;Ea-!tkcDU`jm9e# zxzFL9k-#aPy5O{kA;q0SFBw)@hx5K`>G@9LSLb-G=ipN2L?02=7rti3?OVF3?d90> zX<>75J-BRk2A{^f3jlha%U==^1Cma1q#La9HI$AlYeU zrsG2W>e=+q^ym1^Q`~7IYwcXy_?Hkn5^^-UVZd{!>au(Uw=7Jy0gD85vi8;k@`xo_ zwAo~KOWVx+?=GVwUuTC%2>+-@R%jhIGQ?;&L~5`IOMEqXfq$qyt=fF;y~b}2DLE#) zo_t5-DZ0n|M@?Mq=a1c^e2F4Mf&65d#alI1HJtz->-Uo0q%r;TgQuRi(|kpez8^pP z7dZl_?g&*vH)bU*Nw#U9vU(7Nxh~Q7Zf6TOVeh^ew}(B`0h+fh~Z!aQdojs0;P}C~YWU9$}T;%K2&j2taBKe``-kPJMa06=9t_ zEpHAp@sZ)71ToqIcj74br-V>z_(Z&iP0Zx^aV~%O_o;J52fWfLNP?`D&5{Y`5gd?) zsMJ*r<5Pm)`1ff}og;*#&yjnpVb9IJyK|$mR`hPe@>;y?z>p%e!esQr5%Vj;SYILF zkG$|-a8z0P3|X-sAvR*TVtJ<3`@EP9l<}rtg(2OK<6x_j3W4^-j3B0bc6VO$#^ojv7Gc)@%W zmaBF!>(x56y<3+D9Xfx8YL|_(<0um6?9B>g;mL%xRy#PpraA;jPKbu$G*8)-rTr0i zaLChAsG6P4nav4qUkJ~9hFtiJ*=c*v{~1;o?c0@LbsxL@AAlzZ+O!Y!!`4C zF#2$rI=jdw&aB$2`&}la3D4B&Fs-@GVKquYMD2As9c{VWQuQkur}V$En@J@UaJ?-S zz1|J4mJWA&;Qg9cw{@^ars8lIXhZ&OMsHW9Nripdm{| zYemFai=#(coFT2&XD-O8q|?&Z$$ zN;T|WG7+o18!P0as&U0?FRh*p9|EGRPs)3bka$ZHTq->@*kdd*T)a#B4b*$Ct1D?s zo`CUPfYMCnhJ-o(eApHiiQREC?UIt0JrRD$^4(dB&UOx>7xRXXzCA>|jVB!`bR4l- zRW2gBlWus|giYtm*n%BP^UEr9vipppSRD)5$5a1+qB}oj{8`yQMjjzFXzx|CbQuUIo@0B-lM|2$qZv*-9!_Bln_)QWT@XLpYH|Oa@A{{*>FCheyJ) zCXz`zlSorYT0^T)CcA!tfxK}InSO*%-XISVk(ly*Ppebh2=QfO%lC8o{*>pv{sYP4 z^lfv@`j){p%Z7sJuGYcelfZL`N{}MvI%=K?AH9+EB18(cnJf7t`)8y!Zn@6P4oOE! zp^ai|h*nzmN5PPr+c{w5{L~-1o;5{oGs#4_S`-+6LY0r!&d5_suuZQb?NK${o8Jeh zzJsg-*U=8b;qUeRQ~jkxWiapVbsiA7oTlwM;Fi~A98OMP9!eBA<Ixx1%)iEqhB8OldW>$4CYj{x>7CLedO*}&*!}jP9a3r)0JZJJ{~c$5eb_viqg{|V zg(vvfm!v0mzROEIS|#Gk&aiVJg|r4W}@ zsB)fh@3dSfXg9*&-=Fs#YKLkHQQ~@tW@%Vz6=z0~Jdhek^uH>0dTO7Hu*_uQy>_d* zOk7aCl0HuZjoHy8IZpl*&sBWa9Y>E54feSHfUSKdBH^&esji7HsC;QjcU@OX4jUO_ zvpXbo{ZdZl7g3HI4Ujfys`_Grm$w!psK>huUR;y(!C4lzx(yn%oHDFj)!W0{g!LQL!7iqC{rdc?BS8k!U z*FBI45sVsF$egCA+YP5+t!1Z3+!#48z3rM`j|#L@sDeLeCu-p7j*S5)o_&=+7+avv zsODA_{xl==Fd-N5J_Sa}Yx2UPG-{vw7~xV$;`2y`mZGJ)BA%_|kN&+3nh*()5(2qj zRmhFNIl#2R`ZE2a$DP}|Z*gl3Ytg;e6DL2tXygPnJ)xpB=@ z>sJC*o%zIo*Jo#6>Y|~|O%*Mr2eun@81HWEK7GJURMePV7>Ma9C_%~#29dgPXkNyd zYkh}5EAwc!Q9Q5_a4INGMog2dDA$TT)C|>~;gA2Ba{q3I%zRn%tV3};83_?@k}Yj(tvigMagqXXxvjU6uf5$2x44eFV)xckwz@V%e=}SO ziWI(q0PxU^A_dlu_zYK%!~J*=)|^nOq4I3@bFr>j9%5xqqFY=jKdE$?=K6F5+~-*ye~v#~1Pr^GxSez(MDzmQ1@__Cl3F3#TpE)7)39`}V>QMzjl! z+U)EXFJ}X{CiWA&XwoGXPoK=0`ICD0!O$5mv-1LwSCsw zIGnM~#2}ofAgxInwO~(ak^If%JDhN!OjQs)Sj-;c{+V0T{7I&pmc4kh@s3#5do9|i z7<>NXcv-&IFYd45nUyN3>sbnp18k@7#ap`$kRGK6ScaO4HU%_s+QN&mpGeBacX`-p z$un@=;~lb8GkRM*>ub~r(~8bU6nW2Gv|S#?;`HD$D~Q=Mq<7dnzfeg9Wgbrgvl?I} z&nBboQHW4%HK}9xW*?mz2->`9r-GMYW_8uSuwzd(sQ9xDJ&fliji#w;llsO4+g+7W zi#QTCltvwRLidru+o7cuAh*#SEaAD)(T%C^EbVwJGi`b0vKca{Iw_K(U1-_R0;Oc* zaEZ|ng7rf$$SZ#yygZOChVzsI|M^BvbuSW4KGGY&6~Jb8)|Su;L+?qH+pGt_Ar@ki zXmSF(>&~uR6Kb*zoA7M=f$IJKVzp9_Mn*wg!iAPyc5{s8Q5}jbtOG@Rm=6`QY-?VY zp^RLt$)gj~tq6rdP2~`wM~Jh>-bAP7S(|6(LA``^#Vo^)_EDx?{o}mRF(& zNzY(r6}dQM>^A2cW&j!yiyZ8EFegb(ns{3cYHh{&jdC z^z;7b0|$07;a>84rZw-_#;&W3N7lQu(``DxCHf+0pH9v9k{zRxvH28|)Nrnsz_+`t zpv*R$Ht^;<87RGn8>sh}8q(0nd`eNMk1bAzm&-g7iP!d_44R$Xx~~8>*ilkYC+r_q z6Eg-7agwIvF6WCvoRl9p!E*A1r4U=*H#L+0G4tGagP*hW)%O+B2!oN{?867u_vAqm zXb)`Jd3#}z>GDhkaRFHa%uF=Sdj99%n;1K9cWVo_J0gK_h?qWOcFtPjMYW?hDeJ#O zO0+_bnHdjC5PMAwN4F3yTKtoPehf27R(+1Gj^5)aq*Ca?G}IC2`n05uu2+AYxT=ML z3!JvGbKS>T;h|5cSmL&D;E)}1#gFzD3`*Q(Y%o0t>ph}bv@M$Y#4)({N27JxNYmh> zJ(sYto;nNA+R0Ik@B>>4YeupZi88uWhH#);T81!QTbFLy{4rzfw2jlK8e5|1*MwM5 zIm+;hqKZp+bFPWaG!aZm>6(kpQ*#_KerwlJkl74dh%wB&VjC?$_^IMB;Y0kq6Y&p3 z8s)OebQ<0ioj)mqi|Y$blhP3ZTw0CI=|AjP8+&hnUa>ll{I48LLlSBXBsN~q2||QD z^4^ux9KzFF-T`h1x;>^XLTk$0iI-BnYV=k&uLIs;=B7T$i{HbOCt}0SA{f_U+VzUR zg)z$3tn)vReRGqwHZllCd>!s6>oKt9YWI6TlRi8ZoEJFEGUFMbjDbv{>|?`Ukxc(l zCecF-SmXs?=oLM*K%VyrJz%41R8-KX_y-A)&a&0##7KuiGgzpSCCDKdVcoZV2K0?7V&)Y^86q@ z$z|EGTf*yh!kk9=J!1`PtCTPx|fkU;L*iac(ul3@0@BwH6M(3TN&k4p=+~oCyj6+8hCZ@JARC zF1fI`#lZ$m(EAZJ$P7@<2s>mKXl8^D^7tPieWnkJkWknV*bor#V10dXUdj1q-Q;sH zgJm9E%!V0k`R_pj34%n#0eyfZ1o@7lLt6!zS%Bh3aiO$f!FFO$|0q86h@*fjXbWr; zg8OT0ha&*7j-dnME-@z1{;3@YfPf(XuSf8=Oaw5H666mC%_+04<$_Hk;1By7&@tX$ zZ-Zkr0RIR!3_~#0dJhQ#g7Xb%1s@E;0g;Te0ia7hujzva166Q8-u5Aq^e+fF&Hy+r zw*eI)LO^igLqLG{ssHC-r}#e*ENF9_9I$_)?OF={c?JBT_#YU!mL@YOmlqd=%8L58 z1DR#`cXQx#<3am35P<~@1b|*x;X$`c=zvV5W-%)8UAmzM4_I$J5_5xr6rha>bikXU zoo_%uxxeQ}HVJ_FPi;laV7lGW11*v!tH)zr?K_1}T`j{vd|xLq|c?eA?kWSfJkrX>OYRkekH5PZ`f z16Vs^ngBFzM+H)xL5KdUNBL<67i!WDbeReOfo5o-|B+S%b%1T=4u5SzGo*kw`hjm8 z_B;Ls$h&n0s$fPrb!GxQ0|;EU^n@HR0iGvGy?y6VwvX#yh#*)1G)j^&t8PvIm4tPTpedFye^*_LDToA(&I`ltGJ&?!} zF4Rm0Xe$?7(tREs`Y&B83v8p$24g8fdIcnZTYtl4dE?YF4-BLLN&mzJyjezg1H!Ba z<@8X1Qo*4A|B3<*Xr?!yqc$*zG uOZ;yDechm#Wpk+HUXblDIS6M39q_*o1n?;LKgU5YNPL9>R Date: Mon, 6 Apr 2020 18:05:38 -0700 Subject: [PATCH 287/327] fix packaging tests --- build.gradle | 3 - gradle.properties | 2 +- packaging-test/Makefile | 62 ++++++++----------- packaging-test/run-non-osgi-test.sh | 6 ++ packaging-test/run-osgi-test.sh | 14 +++++ packaging-test/test-app/build.gradle | 45 ++++++++------ packaging-test/test-app/settings.gradle | 1 + .../java/testapp/TestAppOsgiEntryPoint.java | 3 +- 8 files changed, 75 insertions(+), 61 deletions(-) create mode 100755 packaging-test/run-non-osgi-test.sh create mode 100755 packaging-test/run-osgi-test.sh create mode 100644 packaging-test/test-app/settings.gradle diff --git a/build.gradle b/build.gradle index 452a3af42..4e40ec5bd 100644 --- a/build.gradle +++ b/build.gradle @@ -236,15 +236,12 @@ def getPackagesInDependencyJar(jarFile) { def shadeDependencies(jarTask) { def excludePackages = getAllSdkPackages() + configurations.shadow.collectMany { getPackagesInDependencyJar(it)} - System.err.println("*** exclude: " + excludePackages) def topLevelPackages = configurations.internal.collectMany { getPackagesInDependencyJar(it).collect { it.contains(".") ? it.substring(0, it.indexOf(".")) : it } }. unique() - System.err.println("*** topLevel: " + topLevelPackages) topLevelPackages.forEach { top -> - System.err.println("*** will relocate: " + top) jarTask.relocate(top, "com.launchdarkly.shaded." + top) { excludePackages.forEach { exclude(it + ".*") } } diff --git a/gradle.properties b/gradle.properties index a6a4f6166..df3a1cc55 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -version=4.12.2-SNAPSHOT +version=4.12.2 # 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= diff --git a/packaging-test/Makefile b/packaging-test/Makefile index e69d2cf40..57878ec66 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -18,39 +18,31 @@ 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 +export TEMP_DIR=$(BASE_DIR)/temp +export 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 -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.3.tar.gz -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 +export FELIX_DIR=$(TEMP_DIR)/felix +export FELIX_JAR=$(FELIX_DIR)/lib/felix.jar +export FELIX_BASE_BUNDLE_DIR=$(FELIX_DIR)/base-bundles +export TEMP_BUNDLE_DIR=$(FELIX_DIR)/app-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) \ - $(SLF4J_SIMPLE_JAR) +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) + $(shell ls $(TEMP_DIR)/dependencies-external/*.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) + $(shell ls $(TEMP_DIR)/dependencies-external/*.jar) # The test-app displays this message on success -SUCCESS_MESSAGE="@@@ successfully created LD client @@@" +export 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 @@ -73,21 +65,12 @@ clean: # 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 +test-all-jar test-default-jar test-thin-jar: $$(RUN_JARS_$$@) $(TEST_APP_JAR) $(FELIX_DIR) 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 + ./run-non-osgi-test.sh $(RUN_JARS_$@) # 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; \ + ./run-osgi-test.sh $(RUN_JARS_$@); \ fi test-all-jar-classes: $(SDK_ALL_JAR) $(TEMP_DIR) @@ -144,13 +127,18 @@ $(TEMP_DIR)/dependencies-internal: $(TEMP_DIR)/dependencies-all cp $(TEMP_DIR)/dependencies-all/*.jar $@ rm $@/gson*.jar $@/slf4j*.jar -$(SLF4J_SIMPLE_JAR): | $(TEMP_DIR) - curl -f -L $(SLF4J_SIMPLE_JAR_URL) >$@ - -$(FELIX_JAR): | $(TEMP_DIR) - curl -f -L $(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 +$(FELIX_JAR): $(FELIX_DIR) + +$(FELIX_DIR): + mkdir -p $(FELIX_DIR) + mkdir -p $(FELIX_DIR)/lib + mkdir -p $(FELIX_BASE_BUNDLE_DIR) + cd test-app && ../../gradlew createOsgi + cp -r test-app/build/osgi/conf $(FELIX_DIR) + echo "felix.shutdown.hook=false" >>$(FELIX_DIR)/conf/config.properties # allows our test app to use System.exit() + cp test-app/build/osgi/system-libs/org.apache.felix.main-*.jar $(FELIX_JAR) + cp test-app/build/osgi/bundle/* $(FELIX_BASE_BUNDLE_DIR) + cd $(FELIX_BASE_BUNDLE_DIR) && rm -f launchdarkly-*.jar gson-*.jar $(TEMP_DIR): [ -d $@ ] || mkdir -p $@ diff --git a/packaging-test/run-non-osgi-test.sh b/packaging-test/run-non-osgi-test.sh new file mode 100755 index 000000000..dcf9c24db --- /dev/null +++ b/packaging-test/run-non-osgi-test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +echo "" +echo " non-OSGi runtime test" +java -classpath $(echo "$@" | sed -e 's/ /:/g') testapp.TestApp | tee ${TEMP_OUTPUT} +grep "${SUCCESS_MESSAGE}" ${TEMP_OUTPUT} >/dev/null diff --git a/packaging-test/run-osgi-test.sh b/packaging-test/run-osgi-test.sh new file mode 100755 index 000000000..62439fedf --- /dev/null +++ b/packaging-test/run-osgi-test.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +echo "" +echo " OSGi runtime test" +rm -rf ${TEMP_BUNDLE_DIR} +mkdir -p ${TEMP_BUNDLE_DIR} +cp $@ ${FELIX_BASE_BUNDLE_DIR}/* ${TEMP_BUNDLE_DIR} +rm -rf ${FELIX_DIR}/felix-cache +rm -f ${TEMP_OUTPUT} +touch ${TEMP_OUTPUT} + +cd ${FELIX_DIR} && java -jar ${FELIX_JAR} -b ${TEMP_BUNDLE_DIR} | tee ${TEMP_OUTPUT} + +grep "${SUCCESS_MESSAGE}" ${TEMP_OUTPUT} >/dev/null diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 02ba7b08f..d7c6b1b1b 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -1,5 +1,17 @@ -apply plugin: "java" -apply plugin: "osgi" + +buildscript { + repositories { + jcenter() + mavenCentral() + } +} + +plugins { + id "java" + id "java-library" + id "biz.aQute.bnd.builder" version "5.0.1" + id "com.athaydes.osgi-run" version "1.6.0" +} repositories { mavenCentral() @@ -8,32 +20,27 @@ repositories { allprojects { group = "com.launchdarkly" version = "1.0.0" + archivesBaseName = 'test-app-bundle' sourceCompatibility = 1.7 targetCompatibility = 1.7 } 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-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" + implementation fileTree(dir: "../../build/libs", include: "launchdarkly-java-server-sdk-*-thin.jar") + implementation "com.google.code.gson:gson:2.7" + implementation "org.slf4j:slf4j-api:1.7.22" + implementation "org.osgi:osgi_R4_core:1.0" + osgiRuntime "org.slf4j:slf4j-simple:1.7.22" } jar { - baseName = 'test-app-bundle' - manifest { - instruction 'Bundle-Activator', 'testapp.TestAppOsgiEntryPoint' - } -} - -task wrapper(type: Wrapper) { - gradleVersion = '4.10.2' + bnd( + 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint' + ) } -buildscript { - repositories { - jcenter() - mavenCentral() - } +runOsgi { + // bundles = [ project ] + bundles = FELIX_GOGO_BUNDLES } diff --git a/packaging-test/test-app/settings.gradle b/packaging-test/test-app/settings.gradle new file mode 100644 index 000000000..e2a1182e2 --- /dev/null +++ b/packaging-test/test-app/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'test-app-bundle' diff --git a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java index f1a9db3ad..ed42ccb1a 100644 --- a/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java +++ b/packaging-test/test-app/src/main/java/testapp/TestAppOsgiEntryPoint.java @@ -8,9 +8,10 @@ public void start(BundleContext context) throws Exception { System.out.println("@@@ starting test bundle @@@"); TestApp.main(new String[0]); + + System.exit(0); } public void stop(BundleContext context) throws Exception { - System.out.println("@@@ stopping test bundle @@@"); } } \ No newline at end of file From a38c9d59af56ac9233764259d00ffd09088dc16b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Apr 2020 18:24:59 -0700 Subject: [PATCH 288/327] better target order --- packaging-test/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 57878ec66..3a2940100 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -65,7 +65,7 @@ clean: # 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_DIR) get-sdk-dependencies $$@-classes +test-all-jar test-default-jar test-thin-jar: $$@-classes get-sdk-dependencies $$(RUN_JARS_$$@) $(TEST_APP_JAR) $(FELIX_DIR) @$(call caption,$@) ./run-non-osgi-test.sh $(RUN_JARS_$@) # Can't currently run the OSGi test for the thin jar, because some of our dependencies aren't available as OSGi bundles. From 83eaa5d183dfea6b89c106c5067894c033973dac Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Apr 2020 18:34:35 -0700 Subject: [PATCH 289/327] comments --- packaging-test/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packaging-test/Makefile b/packaging-test/Makefile index 3a2940100..ffc0bbe30 100644 --- a/packaging-test/Makefile +++ b/packaging-test/Makefile @@ -134,8 +134,11 @@ $(FELIX_DIR): mkdir -p $(FELIX_DIR)/lib mkdir -p $(FELIX_BASE_BUNDLE_DIR) cd test-app && ../../gradlew createOsgi + @# createOsgi is a target provided by the osgi-run Gradle plugin; it downloads the Felix container and + @# puts it in build/osgi along with related bundles and a config file. cp -r test-app/build/osgi/conf $(FELIX_DIR) - echo "felix.shutdown.hook=false" >>$(FELIX_DIR)/conf/config.properties # allows our test app to use System.exit() + echo "felix.shutdown.hook=false" >>$(FELIX_DIR)/conf/config.properties + @# setting felix.shutdown.hook to false allows our test app to use System.exit() cp test-app/build/osgi/system-libs/org.apache.felix.main-*.jar $(FELIX_JAR) cp test-app/build/osgi/bundle/* $(FELIX_BASE_BUNDLE_DIR) cd $(FELIX_BASE_BUNDLE_DIR) && rm -f launchdarkly-*.jar gson-*.jar From c69dc76c81472ae38109551879d03c6114c6def7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 6 Apr 2020 18:40:40 -0700 Subject: [PATCH 290/327] rm unnecessary Gogo CLI bundles --- packaging-test/test-app/build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index d7c6b1b1b..59d3fd936 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -41,6 +41,5 @@ jar { } runOsgi { - // bundles = [ project ] - bundles = FELIX_GOGO_BUNDLES + bundles = [ ] // we don't need a CLI or anything like that - just the SLF4j dependency shown above } From 9aef9cb33fab583553ffa2481156684fff3b4464 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 10 Apr 2020 16:56:18 -0700 Subject: [PATCH 291/327] (4.x) restart stream if we get bad data or a store error (#206) --- .../client/DefaultEventProcessor.java | 2 +- .../client/DefaultFeatureRequestor.java | 10 +- .../com/launchdarkly/client/JsonHelpers.java | 69 +++++ .../launchdarkly/client/PollingProcessor.java | 3 + .../launchdarkly/client/StreamProcessor.java | 237 +++++++++++++----- .../interfaces/SerializationException.java | 23 ++ .../client/utils/FeatureStoreHelpers.java | 3 +- .../launchdarkly/client/FeatureFlagTest.java | 8 +- .../com/launchdarkly/client/LDUserTest.java | 19 +- .../client/StreamProcessorTest.java | 222 ++++++++++++++-- .../com/launchdarkly/client/TestUtil.java | 24 +- 11 files changed, 518 insertions(+), 102 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/interfaces/SerializationException.java diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index d516a9bcc..ba1b6931b 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -685,7 +685,7 @@ Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { return new Runnable() { @Override public void run() { - String json = JsonHelpers.gsonInstance().toJson(diagnosticEvent); + String json = JsonHelpers.serialize(diagnosticEvent); postJson(httpClient, headers, json, uriStr, "diagnostic event", null, null); } }; diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index e9d59fd7b..778e6d7d4 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -2,6 +2,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.io.Files; +import com.launchdarkly.client.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,7 +13,6 @@ import java.util.HashMap; import java.util.Map; -import static com.launchdarkly.client.JsonHelpers.gsonInstance; import static com.launchdarkly.client.Util.configureHttpClientBuilder; import static com.launchdarkly.client.Util.getHeadersBuilderFor; import static com.launchdarkly.client.Util.shutdownHttpClient; @@ -63,19 +63,19 @@ public void close() { shutdownHttpClient(httpClient); } - public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException { + public FeatureFlag getFlag(String featureKey) throws IOException, HttpErrorException, SerializationException { String body = get(GET_LATEST_FLAGS_PATH + "/" + featureKey); - return gsonInstance().fromJson(body, FeatureFlag.class); + return JsonHelpers.deserialize(body, FeatureFlag.class); } public Segment getSegment(String segmentKey) throws IOException, HttpErrorException { String body = get(GET_LATEST_SEGMENTS_PATH + "/" + segmentKey); - return gsonInstance().fromJson(body, Segment.class); + return JsonHelpers.deserialize(body, Segment.class); } public AllData getAllData() throws IOException, HttpErrorException { String body = get(GET_LATEST_ALL_PATH); - return gsonInstance().fromJson(body, AllData.class); + return JsonHelpers.deserialize(body, AllData.class); } static Map, Map> toVersionedDataMap(AllData allData) { diff --git a/src/main/java/com/launchdarkly/client/JsonHelpers.java b/src/main/java/com/launchdarkly/client/JsonHelpers.java index 97f4c95a5..7fb1c0095 100644 --- a/src/main/java/com/launchdarkly/client/JsonHelpers.java +++ b/src/main/java/com/launchdarkly/client/JsonHelpers.java @@ -2,20 +2,28 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; +import com.launchdarkly.client.interfaces.SerializationException; import java.io.IOException; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; + abstract class JsonHelpers { private static final Gson gson = new Gson(); /** * Returns a shared instance of Gson with default configuration. This should not be used for serializing * event data, since it does not have any of the configurable behavior related to private attributes. + * Code in _unit tests_ should _not_ use this method, because the tests can be run from other projects + * in an environment where the classpath contains a shaded copy of Gson instead of regular Gson. */ static Gson gsonInstance() { return gson; @@ -29,7 +37,68 @@ static Gson gsonInstanceForEventsSerialization(EventsConfiguration config) { .registerTypeAdapter(LDUser.class, new LDUser.UserAdapterWithPrivateAttributeBehavior(config)) .create(); } + + /** + * Deserializes an object from JSON. We should use this helper method instead of directly calling + * gson.fromJson() to minimize reliance on details of the framework we're using, and to ensure that we + * consistently use our wrapper exception. + * + * @param json the serialized JSON string + * @param objectClass class of object to create + * @return the deserialized object + * @throws SerializationException if Gson throws an exception + */ + static T deserialize(String json, Class objectClass) throws SerializationException { + try { + return gson.fromJson(json, objectClass); + } catch (Exception e) { + throw new SerializationException(e); + } + } + + /** + * Serializes an object to JSON. We should use this helper method instead of directly calling + * gson.toJson() to minimize reliance on details of the framework we're using (except when we need to use + * gsonInstanceForEventsSerialization, since our event serialization logic isn't well suited to using a + * simple abstraction). + * + * @param o the object to serialize + * @return the serialized JSON string + */ + static String serialize(Object o) { + return gson.toJson(o); + } + /** + * Deserializes a data model object from JSON that was already parsed by Gson. + *

    + * For built-in data model classes, our usual abstraction for deserializing from a string is inefficient in + * this case, because Gson has already parsed the original JSON and then we would have to convert the + * JsonElement back into a string and parse it again. So it's best to call Gson directly instead of going + * through our abstraction in that case, but it's also best to implement that special-casing just once here + * instead of scattered throughout the SDK. + * + * @param kind the data kind + * @param parsedJson the parsed JSON + * @return the deserialized item + */ + static VersionedData deserializeFromParsedJson(VersionedDataKind kind, JsonElement parsedJson) throws SerializationException { + VersionedData item; + try { + if (kind == FEATURES) { + item = gson.fromJson(parsedJson, FeatureFlag.class); + } else if (kind == SEGMENTS) { + item = gson.fromJson(parsedJson, Segment.class); + } else { + // This shouldn't happen since we only use this method internally with our predefined data kinds + throw new IllegalArgumentException("unknown data kind"); + } + } catch (JsonParseException e) { + throw new SerializationException(e); + } + return item; + } + /** * Implement this interface on any internal class that needs to do some kind of post-processing after * being unmarshaled from JSON. You must also add the annotation {@code JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory)} diff --git a/src/main/java/com/launchdarkly/client/PollingProcessor.java b/src/main/java/com/launchdarkly/client/PollingProcessor.java index 7e97aa3b8..df3bf609a 100644 --- a/src/main/java/com/launchdarkly/client/PollingProcessor.java +++ b/src/main/java/com/launchdarkly/client/PollingProcessor.java @@ -3,6 +3,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.launchdarkly.client.interfaces.SerializationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -76,6 +77,8 @@ public void run() { } catch (IOException e) { logger.error("Encountered exception in LaunchDarkly client when retrieving update: {}", e.toString()); logger.debug(e.toString(), e); + } catch (SerializationException e) { + logger.error("Polling request received malformed data: {}", e.toString()); } } }, 0L, pollIntervalMillis, TimeUnit.MILLISECONDS); diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 764421a69..61d9cf871 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -2,8 +2,8 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; -import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.launchdarkly.client.interfaces.SerializationException; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -15,6 +15,8 @@ import java.io.IOException; import java.net.URI; +import java.util.AbstractMap; +import java.util.Map; import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; @@ -22,12 +24,28 @@ import static com.launchdarkly.client.Util.getHeadersBuilderFor; import static com.launchdarkly.client.Util.httpErrorMessage; import static com.launchdarkly.client.Util.isHttpErrorRecoverable; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import okhttp3.Headers; import okhttp3.OkHttpClient; +/** + * Implementation of the streaming data source, not including the lower-level SSE implementation which is in + * okhttp-eventsource. + * + * Error handling works as follows: + * 1. If any event is malformed, we must assume the stream is broken and we may have missed updates. Restart it. + * 2. If we try to put updates into the data store and we get an error, we must assume something's wrong with the + * data store. We must assume that updates have been lost, so we'll restart the stream. (Starting in version 5.0, + * we will be able to do this in a smarter way and not restart the stream until the store is actually working + * again, but in 4.x we don't have the monitoring mechanism for this.) + * 3. If we receive an unrecoverable error like HTTP 401, we close the stream and don't retry. Any other HTTP + * error or network error causes a retry with backoff. + * 4. We set the Future returned by start() to tell the client initialization logic that initialization has either + * succeeded (we got an initial payload and successfully stored it) or permanently failed (we got a 401, etc.). + * Otherwise, the client initialization method may time out but we will still be retrying in the background, and + * if we succeed then the client can detect that we're initialized now by calling our Initialized method. + */ final class StreamProcessor implements UpdateProcessor { private static final String PUT = "put"; private static final String PATCH = "patch"; @@ -48,6 +66,7 @@ final class StreamProcessor implements UpdateProcessor { private volatile EventSource es; private final AtomicBoolean initialized = new AtomicBoolean(false); private volatile long esStarted = 0; + private volatile boolean lastStoreUpdateFailed = false; ConnectionErrorHandler connectionErrorHandler = createDefaultConnectionErrorHandler(); // exposed for testing @@ -123,77 +142,112 @@ public void onClosed() throws Exception { } @Override - public void onMessage(String name, MessageEvent event) throws Exception { - Gson gson = new Gson(); - switch (name) { - case PUT: { - recordStreamInit(false); - esStarted = 0; - PutData putData = gson.fromJson(event.getData(), PutData.class); - store.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); - if (!initialized.getAndSet(true)) { - initFuture.set(null); - logger.info("Initialized LaunchDarkly client."); + public void onMessage(String name, MessageEvent event) { + try { + switch (name) { + case PUT: { + recordStreamInit(false); + esStarted = 0; + PutData putData = parseStreamJson(PutData.class, event.getData()); + try { + store.init(DefaultFeatureRequestor.toVersionedDataMap(putData.data)); + } catch (Exception e) { + throw new StreamStoreException(e); + } + if (!initialized.getAndSet(true)) { + initFuture.set(null); + logger.info("Initialized LaunchDarkly client."); + } + break; } - break; - } - case PATCH: { - PatchData data = gson.fromJson(event.getData(), PatchData.class); - if (FEATURES.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(FEATURES, gson.fromJson(data.data, FeatureFlag.class)); - } else if (SEGMENTS.getKeyFromStreamApiPath(data.path) != null) { - store.upsert(SEGMENTS, gson.fromJson(data.data, Segment.class)); + case PATCH: { + PatchData data = parseStreamJson(PatchData.class, event.getData()); + Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + break; + } + VersionedDataKind kind = kindAndKey.getKey(); + VersionedData item = deserializeFromParsedJson(kind, data.data); + try { + store.upsert(kind, item); + } catch (Exception e) { + throw new StreamStoreException(e); + } + break; } - break; - } - case DELETE: { - DeleteData data = gson.fromJson(event.getData(), DeleteData.class); - String featureKey = FEATURES.getKeyFromStreamApiPath(data.path); - if (featureKey != null) { - store.delete(FEATURES, featureKey, data.version); - } else { - String segmentKey = SEGMENTS.getKeyFromStreamApiPath(data.path); - if (segmentKey != null) { - store.delete(SEGMENTS, segmentKey, data.version); + case DELETE: { + DeleteData data = parseStreamJson(DeleteData.class, event.getData()); + Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(data.path); + if (kindAndKey == null) { + break; + } + VersionedDataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + try { + store.delete(kind, key, data.version); + } catch (Exception e) { + throw new StreamStoreException(e); } + break; } - break; - } - case INDIRECT_PUT: - try { - FeatureRequestor.AllData allData = requestor.getAllData(); - store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + case INDIRECT_PUT: + FeatureRequestor.AllData allData; + try { + allData = requestor.getAllData(); + } catch (HttpErrorException e) { + throw new StreamInputException(e); + } catch (IOException e) { + throw new StreamInputException(e); + } + try { + store.init(DefaultFeatureRequestor.toVersionedDataMap(allData)); + } catch (Exception e) { + throw new StreamStoreException(e); + } if (!initialized.getAndSet(true)) { initFuture.set(null); logger.info("Initialized LaunchDarkly client."); } - } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); - logger.debug(e.toString(), e); - } - break; - case INDIRECT_PATCH: - String path = event.getData(); - try { - String featureKey = FEATURES.getKeyFromStreamApiPath(path); - if (featureKey != null) { - FeatureFlag feature = requestor.getFlag(featureKey); - store.upsert(FEATURES, feature); - } else { - String segmentKey = SEGMENTS.getKeyFromStreamApiPath(path); - if (segmentKey != null) { - Segment segment = requestor.getSegment(segmentKey); - store.upsert(SEGMENTS, segment); - } + break; + case INDIRECT_PATCH: + String path = event.getData(); + Map.Entry, String> kindAndKey = getKindAndKeyFromStreamApiPath(path); + if (kindAndKey == null) { + break; } - } catch (IOException e) { - logger.error("Encountered exception in LaunchDarkly client: {}", e.toString()); - logger.debug(e.toString(), e); - } - break; - default: - logger.warn("Unexpected event found in stream: " + event.getData()); - break; + VersionedDataKind kind = kindAndKey.getKey(); + String key = kindAndKey.getValue(); + VersionedData item; + try { + item = (Object)kind == SEGMENTS ? requestor.getSegment(key) : requestor.getFlag(key); + } catch (Exception e) { + throw new StreamInputException(e); + } + try { + store.upsert(kind, item); // silly cast due to our use of generics + } catch (Exception e) { + throw new StreamStoreException(e); + } + break; + default: + logger.warn("Unexpected event found in stream: " + event.getData()); + break; + } + } catch (StreamInputException e) { + logger.error("LaunchDarkly service request failed or received invalid data: {}", e.toString()); + logger.debug(e.toString(), e); + es.restart(); + } catch (StreamStoreException e) { + if (!lastStoreUpdateFailed) { + logger.error("Unexpected data store failure when storing updates from stream: {}", + e.getCause().toString()); + logger.debug(e.getCause().toString(), e.getCause()); + lastStoreUpdateFailed = true; + } + es.restart(); + } catch (Exception e) { + logger.error("Unexpected exception in stream processor: {}", e.toString()); + logger.debug(e.toString(), e); } } @@ -242,6 +296,61 @@ public void close() throws IOException { public boolean initialized() { return initialized.get(); } + + @SuppressWarnings("unchecked") + private static Map.Entry, String> getKindAndKeyFromStreamApiPath(String path) + throws StreamInputException { + if (path == null) { + throw new StreamInputException("missing item path"); + } + for (VersionedDataKind kind: VersionedDataKind.ALL) { + String prefix = (kind == SEGMENTS) ? "/segments/" : "/flags/"; + if (path.startsWith(prefix)) { + return new AbstractMap.SimpleEntry, String>( + (VersionedDataKind)kind, // cast is required due to our cumbersome use of generics + path.substring(prefix.length())); + } + } + return null; // we don't recognize the path - the caller should ignore this event, just as we ignore unknown event types + } + + private static T parseStreamJson(Class c, String json) throws StreamInputException { + try { + return JsonHelpers.deserialize(json, c); + } catch (SerializationException e) { + throw new StreamInputException(e); + } + } + + private static VersionedData deserializeFromParsedJson(VersionedDataKind kind, JsonElement parsedJson) + throws StreamInputException { + try { + return JsonHelpers.deserializeFromParsedJson(kind, parsedJson); + } catch (SerializationException e) { + throw new StreamInputException(e); + } + } + + // StreamInputException is either a JSON parsing error *or* a failure to query another endpoint + // (for indirect/put or indirect/patch); either way, it implies that we were unable to get valid data from LD services. + @SuppressWarnings("serial") + private static final class StreamInputException extends Exception { + public StreamInputException(String message) { + super(message); + } + + public StreamInputException(Throwable cause) { + super(cause); + } + } + + // This exception class indicates that the data store failed to persist an update. + @SuppressWarnings("serial") + private static final class StreamStoreException extends Exception { + public StreamStoreException(Throwable cause) { + super(cause); + } + } private static final class PutData { FeatureRequestor.AllData data; diff --git a/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java b/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java new file mode 100644 index 000000000..0473c991f --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/SerializationException.java @@ -0,0 +1,23 @@ +package com.launchdarkly.client.interfaces; + +/** + * General exception class for all errors in serializing or deserializing JSON. + *

    + * The SDK uses this class to avoid depending on exception types from the underlying JSON framework + * that it uses (currently Gson). + *

    + * This is currently an unchecked exception, because adding checked exceptions to existing SDK + * interfaces would be a breaking change. In the future it will become a checked exception, to make + * error-handling requirements clearer. However, public SDK client methods will not throw this + * exception in any case; it is only relevant when implementing custom components. + */ +@SuppressWarnings("serial") +public class SerializationException extends RuntimeException { + /** + * Creates an instance. + * @param cause the underlying exception + */ + public SerializationException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java index 6fefb8e62..e49cbb7c7 100644 --- a/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java +++ b/src/main/java/com/launchdarkly/client/utils/FeatureStoreHelpers.java @@ -5,6 +5,7 @@ import com.launchdarkly.client.FeatureStore; import com.launchdarkly.client.VersionedData; import com.launchdarkly.client.VersionedDataKind; +import com.launchdarkly.client.interfaces.SerializationException; /** * Helper methods that may be useful for implementing a {@link FeatureStore} or {@link FeatureStoreCore}. @@ -48,7 +49,7 @@ public static String marshalJson(VersionedData item) { * Thrown by {@link FeatureStoreHelpers#unmarshalJson(VersionedDataKind, String)} for a deserialization error. */ @SuppressWarnings("serial") - public static class UnmarshalException extends RuntimeException { + public static class UnmarshalException extends SerializationException { /** * Constructs an instance. * @param cause the underlying exception diff --git a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java index 71791785c..c8c072605 100644 --- a/src/test/java/com/launchdarkly/client/FeatureFlagTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureFlagTest.java @@ -12,7 +12,7 @@ import java.util.Arrays; import static com.launchdarkly.client.EvaluationDetail.fromValue; -import static com.launchdarkly.client.JsonHelpers.gsonInstance; +import static com.launchdarkly.client.TestUtil.TEST_GSON_INSTANCE; import static com.launchdarkly.client.TestUtil.booleanFlagWithClauses; import static com.launchdarkly.client.TestUtil.fallthroughVariation; import static com.launchdarkly.client.VersionedDataKind.FEATURES; @@ -534,17 +534,17 @@ public void testSegmentMatchClauseFallsThroughIfSegmentNotFound() throws Excepti @Test public void flagIsDeserializedWithAllProperties() { String json = flagWithAllPropertiesJson().toJsonString(); - FeatureFlag flag0 = gsonInstance().fromJson(json, FeatureFlag.class); + FeatureFlag flag0 = TEST_GSON_INSTANCE.fromJson(json, FeatureFlag.class); assertFlagHasAllProperties(flag0); - FeatureFlag flag1 = gsonInstance().fromJson(gsonInstance().toJson(flag0), FeatureFlag.class); + FeatureFlag flag1 = TEST_GSON_INSTANCE.fromJson(TEST_GSON_INSTANCE.toJson(flag0), FeatureFlag.class); assertFlagHasAllProperties(flag1); } @Test public void flagIsDeserializedWithMinimalProperties() { String json = LDValue.buildObject().put("key", "flag-key").put("version", 99).build().toJsonString(); - FeatureFlag flag = gsonInstance().fromJson(json, FeatureFlag.class); + FeatureFlag flag = TEST_GSON_INSTANCE.fromJson(json, FeatureFlag.class); assertEquals("flag-key", flag.getKey()); assertEquals(99, flag.getVersion()); assertFalse(flag.isOn()); diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index c4c6adeb8..fd66966f6 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -18,8 +18,8 @@ import java.util.Map; import java.util.Set; -import static com.launchdarkly.client.JsonHelpers.gsonInstance; import static com.launchdarkly.client.JsonHelpers.gsonInstanceForEventsSerialization; +import static com.launchdarkly.client.TestUtil.TEST_GSON_INSTANCE; import static com.launchdarkly.client.TestUtil.defaultEventsConfig; import static com.launchdarkly.client.TestUtil.jbool; import static com.launchdarkly.client.TestUtil.jdouble; @@ -172,6 +172,7 @@ public void canSetAnonymous() { assertEquals(true, user.getAnonymous().booleanValue()); } + @SuppressWarnings("deprecation") @Test public void canSetCountry() { LDUser user = new LDUser.Builder("key").country(LDCountryCode.US).build(); @@ -216,6 +217,7 @@ public void invalidCountryNameDoesNotSetCountry() { assertEquals(LDValue.ofNull(), user.getCountry()); } + @SuppressWarnings("deprecation") @Test public void canSetPrivateCountry() { LDUser user = new LDUser.Builder("key").privateCountry(LDCountryCode.US).build(); @@ -297,8 +299,8 @@ public void canSetPrivateDeprecatedCustomJsonValue() { @Test public void testAllPropertiesInDefaultEncoding() { for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); - JsonElement actual = gsonInstance().toJsonTree(e.getKey()); + JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); assertEquals(expected, actual); } } @@ -306,12 +308,13 @@ public void testAllPropertiesInDefaultEncoding() { @Test public void testAllPropertiesInPrivateAttributeEncoding() { for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { - JsonElement expected = gsonInstance().fromJson(e.getValue(), JsonElement.class); - JsonElement actual = gsonInstance().toJsonTree(e.getKey()); + JsonElement expected = TEST_GSON_INSTANCE.fromJson(e.getValue(), JsonElement.class); + JsonElement actual = TEST_GSON_INSTANCE.toJsonTree(e.getKey()); assertEquals(expected, actual); } } + @SuppressWarnings("deprecation") private Map getUserPropertiesJsonMap() { ImmutableMap.Builder builder = ImmutableMap.builder(); builder.put(new LDUser.Builder("userkey").build(), "{\"key\":\"userkey\"}"); @@ -467,7 +470,7 @@ public void canAddCustomAttrWithListOfStrings() { .customString("foo", ImmutableList.of("a", "b")) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), js("b")); - JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); + JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } @@ -477,7 +480,7 @@ public void canAddCustomAttrWithListOfNumbers() { .customNumber("foo", ImmutableList.of(new Integer(1), new Double(2))) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", jint(1), jdouble(2)); - JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); + JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } @@ -487,7 +490,7 @@ public void canAddCustomAttrWithListOfMixedValues() { .customValues("foo", ImmutableList.of(js("a"), jint(1), jbool(true))) .build(); JsonElement expectedAttr = makeCustomAttrWithListOfValues("foo", js("a"), jint(1), jbool(true)); - JsonObject jo = gsonInstance().toJsonTree(user).getAsJsonObject(); + JsonObject jo = TEST_GSON_INSTANCE.toJsonTree(user).getAsJsonObject(); assertEquals(expectedAttr, jo.get("custom")); } diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index 8b4460a16..d204c253a 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -24,9 +24,11 @@ import static com.launchdarkly.client.TestHttpUtil.eventStreamResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.client.TestUtil.featureStoreThatThrowsException; import static com.launchdarkly.client.VersionedDataKind.FEATURES; import static com.launchdarkly.client.VersionedDataKind.SEGMENTS; import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.expectLastCall; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; @@ -166,6 +168,8 @@ public void headersHaveWrapperWhenSet() { @Test public void putCausesFeatureToBeStored() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{\"" + FEATURE1_KEY + "\":" + featureJson(FEATURE1_KEY, FEATURE1_VERSION) + "}," + @@ -177,6 +181,8 @@ public void putCausesFeatureToBeStored() throws Exception { @Test public void putCausesSegmentToBeStored() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); MessageEvent event = new MessageEvent("{\"data\":{\"flags\":{},\"segments\":{\"" + SEGMENT1_KEY + "\":" + segmentJson(SEGMENT1_KEY, SEGMENT1_VERSION) + "}}}"); @@ -230,6 +236,8 @@ public void putCausesFutureToBeSet() throws Exception { @Test public void patchUpdatesFeature() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); @@ -243,6 +251,8 @@ public void patchUpdatesFeature() throws Exception { @Test public void patchUpdatesSegment() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); @@ -256,6 +266,8 @@ public void patchUpdatesSegment() throws Exception { @Test public void deleteDeletesFeature() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); featureStore.upsert(FEATURES, FEATURE); @@ -270,6 +282,8 @@ public void deleteDeletesFeature() throws Exception { @Test public void deleteDeletesSegment() throws Exception { + expectNoStreamRestart(); + createStreamProcessor(STREAM_URI).start(); eventHandler.onMessage("put", emptyPutEvent()); featureStore.upsert(SEGMENTS, SEGMENT); @@ -284,13 +298,17 @@ public void deleteDeletesSegment() throws Exception { @Test public void indirectPutRequestsAndStoresFeature() throws Exception { - createStreamProcessor(STREAM_URI).start(); setupRequestorToReturnAllDataWithFlag(FEATURE); + expectNoStreamRestart(); replayAll(); - eventHandler.onMessage("indirect/put", new MessageEvent("")); + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + + eventHandler.onMessage("indirect/put", new MessageEvent("")); - assertFeatureInStore(FEATURE); + assertFeatureInStore(FEATURE); + } } @Test @@ -329,27 +347,35 @@ public void indirectPutSetsFuture() throws Exception { } @Test - public void indirectPatchRequestsAndUpdatesFeature() throws Exception { - createStreamProcessor(STREAM_URI).start(); + public void indirectPatchRequestsAndUpdatesFeature() throws Exception { expect(mockRequestor.getFlag(FEATURE1_KEY)).andReturn(FEATURE); + expectNoStreamRestart(); replayAll(); - - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); - - assertFeatureInStore(FEATURE); + + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + + eventHandler.onMessage("put", emptyPutEvent()); + eventHandler.onMessage("indirect/patch", new MessageEvent("/flags/" + FEATURE1_KEY)); + + assertFeatureInStore(FEATURE); + } } @Test public void indirectPatchRequestsAndUpdatesSegment() throws Exception { - createStreamProcessor(STREAM_URI).start(); expect(mockRequestor.getSegment(SEGMENT1_KEY)).andReturn(SEGMENT); + expectNoStreamRestart(); replayAll(); - eventHandler.onMessage("put", emptyPutEvent()); - eventHandler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); - - assertSegmentInStore(SEGMENT); + try (StreamProcessor sp = createStreamProcessor(STREAM_URI)) { + sp.start(); + + eventHandler.onMessage("put", emptyPutEvent()); + eventHandler.onMessage("indirect/patch", new MessageEvent("/segments/" + SEGMENT1_KEY)); + + assertSegmentInStore(SEGMENT); + } } @Test @@ -438,7 +464,168 @@ public void http429ErrorIsRecoverable() throws Exception { public void http500ErrorIsRecoverable() throws Exception { testRecoverableHttpError(500); } + + @Test + public void putEventWithInvalidJsonCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("put", "{sorry"); + } + + @Test + public void putEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("put", "{\"data\":{\"flags\":3}}"); + } + + @Test + public void patchEventWithInvalidJsonCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("patch", "{sorry"); + } + + @Test + public void patchEventWithWellFormedJsonButInvalidDataCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("patch", "{\"path\":\"/flags/flagkey\", \"data\":{\"rules\":3}}"); + } + + @Test + public void patchEventWithInvalidPathCausesNoStreamRestart() throws Exception { + verifyEventCausesNoStreamRestart("patch", "{\"path\":\"/wrong\", \"data\":{\"key\":\"flagkey\"}}"); + } + + @Test + public void deleteEventWithInvalidJsonCausesStreamRestart() throws Exception { + verifyEventCausesStreamRestart("delete", "{sorry"); + } + + @Test + public void deleteEventWithInvalidPathCausesNoStreamRestart() throws Exception { + verifyEventCausesNoStreamRestart("delete", "{\"path\":\"/wrong\", \"version\":1}"); + } + + @Test + public void indirectPatchEventWithInvalidPathDoesNotCauseStreamRestart() throws Exception { + verifyEventCausesNoStreamRestart("indirect/patch", "/wrong"); + } + + @Test + public void indirectPutWithFailedPollCausesStreamRestart() throws Exception { + expect(mockRequestor.getAllData()).andThrow(new IOException("sorry")); + verifyEventCausesStreamRestart("indirect/put", ""); + } + + @Test + public void indirectPatchWithFailedPollCausesStreamRestart() throws Exception { + expect(mockRequestor.getFlag("flagkey")).andThrow(new IOException("sorry")); + verifyEventCausesStreamRestart("indirect/patch", "/flags/flagkey"); + } + + @Test + public void storeFailureOnPutCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("put", emptyPutEvent()); + } + verifyAll(); + } + @Test + public void storeFailureOnPatchCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("patch", + new MessageEvent("{\"path\":\"/flags/flagkey\",\"data\":{\"key\":\"flagkey\",\"version\":1}}")); + } + verifyAll(); + } + + @Test + public void storeFailureOnDeleteCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("delete", + new MessageEvent("{\"path\":\"/flags/flagkey\",\"version\":1}")); + } + verifyAll(); + } + + @Test + public void storeFailureOnIndirectPutCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + setupRequestorToReturnAllDataWithFlag(FEATURE); + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("indirect/put", new MessageEvent("")); + } + verifyAll(); + } + + @Test + public void storeFailureOnIndirectPatchCausesStreamRestart() throws Exception { + FeatureStore badStore = featureStoreThatThrowsException(new RuntimeException("sorry")); + setupRequestorToReturnAllDataWithFlag(FEATURE); + + expectStreamRestart(); + replayAll(); + + try (StreamProcessor sp = createStreamProcessorWithStore(badStore)) { + sp.start(); + eventHandler.onMessage("indirect/put", new MessageEvent("")); + } + verifyAll(); + } + + private void verifyEventCausesNoStreamRestart(String eventName, String eventData) throws Exception { + expectNoStreamRestart(); + verifyEventBehavior(eventName, eventData); + } + + private void verifyEventCausesStreamRestart(String eventName, String eventData) throws Exception { + expectStreamRestart(); + verifyEventBehavior(eventName, eventData); + } + + private void verifyEventBehavior(String eventName, String eventData) throws Exception { + replayAll(); + try (StreamProcessor sp = createStreamProcessor(LDConfig.DEFAULT, STREAM_URI, null)) { + sp.start(); + eventHandler.onMessage(eventName, new MessageEvent(eventData)); + } + verifyAll(); + } + + private void expectNoStreamRestart() throws Exception { + mockEventSource.start(); + expectLastCall().times(1); + mockEventSource.close(); + expectLastCall().times(1); + mockRequestor.close(); + expectLastCall().times(1); + } + + private void expectStreamRestart() throws Exception { + mockEventSource.start(); + expectLastCall().times(1); + mockEventSource.restart(); + expectLastCall().times(1); + mockEventSource.close(); + expectLastCall().times(1); + mockRequestor.close(); + expectLastCall().times(1); + } + // There are already end-to-end tests against an HTTP server in okhttp-eventsource, so we won't retest the // basic stream mechanism in detail. However, we do want to make sure that the LDConfig options are correctly // applied to the EventSource for things like TLS configuration. @@ -569,6 +756,11 @@ private StreamProcessor createStreamProcessorWithRealHttp(LDConfig config, URI s streamUri, config.deprecatedReconnectTimeMs); } + private StreamProcessor createStreamProcessorWithStore(FeatureStore store) { + return new StreamProcessor(SDK_KEY, LDConfig.DEFAULT.httpConfig, mockRequestor, store, + new StubEventSourceCreator(), null, STREAM_URI, 0); + } + private String featureJson(String key, int version) { return "{\"key\":\"" + key + "\",\"version\":" + version + ",\"on\":true}"; } diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 82eb9dc03..98663931d 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -2,6 +2,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.SettableFuture; +import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonPrimitive; @@ -26,7 +27,16 @@ @SuppressWarnings("javadoc") public class TestUtil { - + /** + * We should use this instead of JsonHelpers.gsonInstance() in any test code that might be run from + * outside of this project (for instance, from java-server-sdk-redis or other integrations), because + * in that context the SDK classes might be coming from the default jar distribution where Gson is + * shaded. Therefore, if a test method tries to call an SDK implementation method like gsonInstance() + * that returns a Gson type, or one that takes an argument of a Gson type, that might fail at runtime + * because the Gson type has been changed to a shaded version. + */ + public static final Gson TEST_GSON_INSTANCE = new Gson(); + public static FeatureStoreFactory specificFeatureStore(final FeatureStore store) { return new FeatureStoreFactory() { public FeatureStore createFeatureStore() { @@ -93,13 +103,19 @@ public Map all(VersionedDataKind kind) { } @Override - public void init(Map, Map> allData) { } + public void init(Map, Map> allData) { + throw e; + } @Override - public void delete(VersionedDataKind kind, String key, int version) { } + public void delete(VersionedDataKind kind, String key, int version) { + throw e; + } @Override - public void upsert(VersionedDataKind kind, T item) { } + public void upsert(VersionedDataKind kind, T item) { + throw e; + } @Override public boolean initialized() { From c3801438fc56337385222242571208ca60355112 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 15 Apr 2020 19:14:50 -0700 Subject: [PATCH 292/327] add scoped configuration for HTTP options --- .../com/launchdarkly/client/Components.java | 86 ++++++++ .../client/DefaultEventProcessor.java | 1 + .../client/DefaultFeatureRequestor.java | 1 + .../launchdarkly/client/DiagnosticEvent.java | 23 +- .../client/HttpConfiguration.java | 39 ---- .../client/HttpConfigurationImpl.java | 66 ++++++ .../com/launchdarkly/client/LDClient.java | 8 + .../com/launchdarkly/client/LDConfig.java | 163 ++++++-------- .../launchdarkly/client/StreamProcessor.java | 1 + .../java/com/launchdarkly/client/Util.java | 61 +++-- .../HttpConfigurationBuilder.java | 139 ++++++++++++ .../client/interfaces/HttpAuthentication.java | 52 +++++ .../client/interfaces/HttpConfiguration.java | 73 ++++++ .../interfaces/HttpConfigurationFactory.java | 16 ++ .../client/DefaultEventProcessorTest.java | 24 +- .../client/DiagnosticEventTest.java | 55 ++++- .../client/DiagnosticSdkTest.java | 8 +- .../client/FeatureRequestorTest.java | 6 +- .../client/LDClientEndToEndTest.java | 6 +- .../com/launchdarkly/client/LDConfigTest.java | 208 +++++++++++++----- .../client/StreamProcessorTest.java | 12 +- .../com/launchdarkly/client/UtilTest.java | 34 +-- .../HttpConfigurationBuilderTest.java | 141 ++++++++++++ 23 files changed, 930 insertions(+), 293 deletions(-) delete mode 100644 src/main/java/com/launchdarkly/client/HttpConfiguration.java create mode 100644 src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java create mode 100644 src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java create mode 100644 src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index 3d7d94e5c..f446eb8e6 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -2,21 +2,28 @@ import com.launchdarkly.client.DiagnosticEvent.ConfigProperty; import com.launchdarkly.client.integrations.EventProcessorBuilder; +import com.launchdarkly.client.integrations.HttpConfigurationBuilder; import com.launchdarkly.client.integrations.PersistentDataStoreBuilder; import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; import com.launchdarkly.client.utils.CachingStoreWrapper; import com.launchdarkly.client.utils.FeatureStoreCore; import com.launchdarkly.client.value.LDValue; import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; import java.net.URI; import java.util.concurrent.Future; import static com.google.common.util.concurrent.Futures.immediateFuture; +import okhttp3.Credentials; + /** * Provides configurable factories for the standard implementations of LaunchDarkly component interfaces. *

    @@ -313,6 +320,55 @@ public static UpdateProcessorFactory nullUpdateProcessor() { return nullUpdateProcessorFactory; } + /** + * Returns a configurable factory for the SDK's networking configuration. + *

    + * Passing this to {@link LDConfig.Builder#http(com.launchdarkly.client.interfaces.HttpConfigurationFactory)} + * applies this configuration to all HTTP/HTTPS requests made by the SDK. + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .http(
    +   *              Components.httpConfiguration()
    +   *                  .connectTimeoutMillis(3000)
    +   *                  .proxyHostAndPort("my-proxy", 8080)
    +   *         )
    +   *         .build();
    +   * 
    + *

    + * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link LDConfig.Builder#connectTimeout(int)}. However, setting {@link LDConfig.Builder#offline(boolean)} + * to {@code true} will supersede these settings and completely disable network requests. + * + * @return a factory object + * @since 4.13.0 + * @see LDConfig.Builder#http(com.launchdarkly.client.interfaces.HttpConfigurationFactory) + */ + public static HttpConfigurationBuilder httpConfiguration() { + return new HttpConfigurationBuilderImpl(); + } + + /** + * Configures HTTP basic authentication, for use with a proxy server. + *

    
    +   *     LDConfig config = new LDConfig.Builder()
    +   *         .http(
    +   *              Components.httpConfiguration()
    +   *                  .proxyHostAndPort("my-proxy", 8080)
    +   *                  .proxyAuthentication(Components.httpBasicAuthentication("username", "password"))
    +   *         )
    +   *         .build();
    +   * 
    + * + * @param username the username + * @param password the password + * @return the basic authentication strategy + * @since 4.13.0 + * @see HttpConfigurationBuilder#proxyAuth(HttpAuthentication) + */ + public static HttpAuthentication httpBasicAuthentication(String username, String password) { + return new HttpBasicAuthentication(username, password); + } + private static final class InMemoryFeatureStoreFactory implements FeatureStoreFactory, DiagnosticDescription { @Override public FeatureStore createFeatureStore() { @@ -646,6 +702,36 @@ public LDValue describeConfiguration(LDConfig config) { } } + private static final class HttpConfigurationBuilderImpl extends HttpConfigurationBuilder { + @Override + public HttpConfiguration createHttpConfiguration() { + return new HttpConfigurationImpl( + connectTimeoutMillis, + proxyHost == null ? null : new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)), + proxyAuth, + socketTimeoutMillis, + sslSocketFactory, + trustManager, + wrapperName == null ? null : (wrapperVersion == null ? wrapperName : (wrapperName + "/" + wrapperVersion)) + ); + } + } + + private static final class HttpBasicAuthentication implements HttpAuthentication { + private final String username; + private final String password; + + HttpBasicAuthentication(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public String provideAuthorization(Iterable challenges) { + return Credentials.basic(username, password); + } + } + private static final class PersistentDataStoreBuilderImpl extends PersistentDataStoreBuilder implements DiagnosticDescription { public PersistentDataStoreBuilderImpl(PersistentDataStoreFactory persistentDataStoreFactory) { super(persistentDataStoreFactory); diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index ba1b6931b..0efa8e46e 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -3,6 +3,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.client.EventSummarizer.EventSummary; +import com.launchdarkly.client.interfaces.HttpConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java index 778e6d7d4..017bcdc73 100644 --- a/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java +++ b/src/main/java/com/launchdarkly/client/DefaultFeatureRequestor.java @@ -2,6 +2,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.io.Files; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.interfaces.SerializationException; import org.slf4j.Logger; diff --git a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java index 62f599a21..4439f3261 100644 --- a/src/main/java/com/launchdarkly/client/DiagnosticEvent.java +++ b/src/main/java/com/launchdarkly/client/DiagnosticEvent.java @@ -91,10 +91,10 @@ static LDValue getConfigurationData(LDConfig config) { ObjectBuilder builder = LDValue.buildObject(); // Add the top-level properties that are not specific to a particular component type. - builder.put("connectTimeoutMillis", config.httpConfig.connectTimeoutUnit.toMillis(config.httpConfig.connectTimeout)); - builder.put("socketTimeoutMillis", config.httpConfig.socketTimeoutUnit.toMillis(config.httpConfig.socketTimeout)); - builder.put("usingProxy", config.httpConfig.proxy != null); - builder.put("usingProxyAuthenticator", config.httpConfig.proxyAuthenticator != null); + builder.put("connectTimeoutMillis", config.httpConfig.getConnectTimeoutMillis()); + builder.put("socketTimeoutMillis", config.httpConfig.getSocketTimeoutMillis()); + builder.put("usingProxy", config.httpConfig.getProxy() != null); + builder.put("usingProxyAuthenticator", config.httpConfig.getProxyAuthentication() != null); builder.put("offline", config.offline); builder.put("startWaitMillis", config.startWaitMillis); @@ -155,8 +155,19 @@ static class DiagnosticSdk { final String wrapperVersion; DiagnosticSdk(LDConfig config) { - this.wrapperName = config.httpConfig.wrapperName; - this.wrapperVersion = config.httpConfig.wrapperVersion; + String id = config.httpConfig.getWrapperIdentifier(); + if (id == null) { + this.wrapperName = null; + this.wrapperVersion = null; + } else { + if (id.indexOf("/") >= 0) { + this.wrapperName = id.substring(0, id.indexOf("/")); + this.wrapperVersion = id.substring(id.indexOf("/") + 1); + } else { + this.wrapperName = id; + this.wrapperVersion = null; + } + } } } diff --git a/src/main/java/com/launchdarkly/client/HttpConfiguration.java b/src/main/java/com/launchdarkly/client/HttpConfiguration.java deleted file mode 100644 index 7ca4593c6..000000000 --- a/src/main/java/com/launchdarkly/client/HttpConfiguration.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.launchdarkly.client; - -import java.net.Proxy; -import java.util.concurrent.TimeUnit; - -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.X509TrustManager; - -import okhttp3.Authenticator; - -// Used internally to encapsulate top-level HTTP configuration that applies to all components. -final class HttpConfiguration { - final int connectTimeout; - final TimeUnit connectTimeoutUnit; - final Proxy proxy; - final Authenticator proxyAuthenticator; - final int socketTimeout; - final TimeUnit socketTimeoutUnit; - final SSLSocketFactory sslSocketFactory; - final X509TrustManager trustManager; - final String wrapperName; - final String wrapperVersion; - - HttpConfiguration(int connectTimeout, TimeUnit connectTimeoutUnit, Proxy proxy, Authenticator proxyAuthenticator, - int socketTimeout, TimeUnit socketTimeoutUnit, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, - String wrapperName, String wrapperVersion) { - super(); - this.connectTimeout = connectTimeout; - this.connectTimeoutUnit = connectTimeoutUnit; - this.proxy = proxy; - this.proxyAuthenticator = proxyAuthenticator; - this.socketTimeout = socketTimeout; - this.socketTimeoutUnit = socketTimeoutUnit; - this.sslSocketFactory = sslSocketFactory; - this.trustManager = trustManager; - this.wrapperName = wrapperName; - this.wrapperVersion = wrapperVersion; - } -} diff --git a/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java b/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java new file mode 100644 index 000000000..c1e9b3e7c --- /dev/null +++ b/src/main/java/com/launchdarkly/client/HttpConfigurationImpl.java @@ -0,0 +1,66 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfiguration; + +import java.net.Proxy; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +final class HttpConfigurationImpl implements HttpConfiguration { + final int connectTimeoutMillis; + final Proxy proxy; + final HttpAuthentication proxyAuth; + final int socketTimeoutMillis; + final SSLSocketFactory sslSocketFactory; + final X509TrustManager trustManager; + final String wrapper; + + HttpConfigurationImpl(int connectTimeoutMillis, Proxy proxy, HttpAuthentication proxyAuth, + int socketTimeoutMillis, SSLSocketFactory sslSocketFactory, X509TrustManager trustManager, + String wrapper) { + this.connectTimeoutMillis = connectTimeoutMillis; + this.proxy = proxy; + this.proxyAuth = proxyAuth; + this.socketTimeoutMillis = socketTimeoutMillis; + this.sslSocketFactory = sslSocketFactory; + this.trustManager = trustManager; + this.wrapper = wrapper; + } + + @Override + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + @Override + public Proxy getProxy() { + return proxy; + } + + @Override + public HttpAuthentication getProxyAuthentication() { + return proxyAuth; + } + + @Override + public int getSocketTimeoutMillis() { + return socketTimeoutMillis; + } + + @Override + public SSLSocketFactory getSslSocketFactory() { + return sslSocketFactory; + } + + @Override + public X509TrustManager getTrustManager() { + return trustManager; + } + + @Override + public String getWrapperIdentifier() { + return wrapper; + } +} diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 7be875e39..058513cd8 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -65,6 +65,14 @@ public LDClient(String sdkKey, LDConfig config) { this.config = new LDConfig(checkNotNull(config, "config must not be null")); this.sdkKey = checkNotNull(sdkKey, "sdkKey must not be null"); + if (config.httpConfig.getProxy() != null) { + if (config.httpConfig.getProxyAuthentication() != null) { + logger.info("Using proxy: {} with authentication.", config.httpConfig.getProxy()); + } else { + logger.info("Using proxy: {} without authentication.", config.httpConfig.getProxy()); + } + } + FeatureStore store; if (this.config.deprecatedFeatureStore != null) { store = this.config.deprecatedFeatureStore; diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index adba16f8d..75e63fd67 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -2,39 +2,25 @@ import com.google.common.collect.ImmutableSet; import com.launchdarkly.client.integrations.EventProcessorBuilder; +import com.launchdarkly.client.integrations.HttpConfigurationBuilder; import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.client.interfaces.HttpConfigurationFactory; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.net.InetSocketAddress; -import java.net.Proxy; import java.net.URI; -import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; -import okhttp3.Authenticator; -import okhttp3.Credentials; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.Route; - /** * This class exposes advanced configuration options for the {@link LDClient}. Instances of this class must be constructed with a {@link com.launchdarkly.client.LDConfig.Builder}. */ public final class LDConfig { - private static final Logger logger = LoggerFactory.getLogger(LDConfig.class); - static final URI DEFAULT_BASE_URI = URI.create("https://app.launchdarkly.com"); static final URI DEFAULT_EVENTS_URI = URI.create("https://events.launchdarkly.com"); static final URI DEFAULT_STREAM_URI = URI.create("https://stream.launchdarkly.com"); private static final int DEFAULT_CAPACITY = 10000; - private static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; - private static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; private static final int DEFAULT_FLUSH_INTERVAL_SECONDS = 5; private static final long MIN_POLLING_INTERVAL_MILLIS = PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS; private static final long DEFAULT_START_WAIT_MILLIS = 5000L; @@ -78,20 +64,20 @@ protected LDConfig(Builder builder) { this.offline = builder.offline; this.startWaitMillis = builder.startWaitMillis; - Proxy proxy = builder.proxy(); - Authenticator proxyAuthenticator = builder.proxyAuthenticator(); - if (proxy != null) { - if (proxyAuthenticator != null) { - logger.info("Using proxy: " + proxy + " with authentication."); - } else { - logger.info("Using proxy: " + proxy + " without authentication."); - } + if (builder.httpConfigFactory != null) { + this.httpConfig = builder.httpConfigFactory.createHttpConfiguration(); + } else { + this.httpConfig = Components.httpConfiguration() + .connectTimeoutMillis(builder.connectTimeoutMillis) + .proxyHostAndPort(builder.proxyPort == -1 ? null : builder.proxyHost, builder.proxyPort) + .proxyAuth(builder.proxyUsername == null || builder.proxyPassword == null ? null : + Components.httpBasicAuthentication(builder.proxyUsername, builder.proxyPassword)) + .socketTimeoutMillis(builder.socketTimeoutMillis) + .sslSocketFactory(builder.sslSocketFactory, builder.trustManager) + .wrapper(builder.wrapperName, builder.wrapperVersion) + .createHttpConfiguration(); } - this.httpConfig = new HttpConfiguration(builder.connectTimeout, builder.connectTimeoutUnit, - proxy, proxyAuthenticator, builder.socketTimeout, builder.socketTimeoutUnit, - builder.sslSocketFactory, builder.trustManager, builder.wrapperName, builder.wrapperVersion); - this.deprecatedAllAttributesPrivate = builder.allAttributesPrivate; this.deprecatedBaseURI = builder.baseURI; this.deprecatedCapacity = builder.capacity; @@ -156,10 +142,9 @@ public static class Builder { private URI baseURI = DEFAULT_BASE_URI; private URI eventsURI = DEFAULT_EVENTS_URI; private URI streamURI = DEFAULT_STREAM_URI; - private int connectTimeout = DEFAULT_CONNECT_TIMEOUT_MILLIS; - private TimeUnit connectTimeoutUnit = TimeUnit.MILLISECONDS; - private int socketTimeout = DEFAULT_SOCKET_TIMEOUT_MILLIS; - private TimeUnit socketTimeoutUnit = TimeUnit.MILLISECONDS; + private HttpConfigurationFactory httpConfigFactory = null; + private int connectTimeoutMillis = HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS; + private int socketTimeoutMillis = HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS; private boolean diagnosticOptOut = false; private int capacity = DEFAULT_CAPACITY; private int flushIntervalSeconds = DEFAULT_FLUSH_INTERVAL_SECONDS; @@ -366,63 +351,68 @@ public Builder stream(boolean stream) { } /** - * Set the connection timeout in seconds for the configuration. This is the time allowed for the underlying HTTP client to connect - * to the LaunchDarkly server. The default is 2 seconds. - *

    Both this method and {@link #connectTimeoutMillis(int) connectTimeoutMillis} affect the same property internally.

    + * Sets the SDK's networking configuration, using a factory object. This object is normally a + * configuration builder obtained from {@link Components#httpConfiguration()}, which has methods + * for setting individual HTTP-related properties. + * + * @param factory the factory object + * @return the builder + * @since 4.13.0 + * @see Components#httpConfiguration() + */ + public Builder http(HttpConfigurationFactory factory) { + this.httpConfigFactory = factory; + return this; + } + + /** + * Deprecated method for setting the connection timeout. * * @param connectTimeout the connection timeout in seconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeoutMillis(int)}. */ public Builder connectTimeout(int connectTimeout) { - this.connectTimeout = connectTimeout; - this.connectTimeoutUnit = TimeUnit.SECONDS; - return this; + return connectTimeoutMillis(connectTimeout * 1000); } /** - * Set the socket timeout in seconds for the configuration. This is the number of seconds between successive packets that the - * client will tolerate before flagging an error. The default is 10 seconds. - *

    Both this method and {@link #socketTimeoutMillis(int) socketTimeoutMillis} affect the same property internally.

    + * Deprecated method for setting the socket read timeout. * * @param socketTimeout the socket timeout in seconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#socketTimeoutMillis(int)}. */ public Builder socketTimeout(int socketTimeout) { - this.socketTimeout = socketTimeout; - this.socketTimeoutUnit = TimeUnit.SECONDS; - return this; + return socketTimeoutMillis(socketTimeout * 1000); } /** - * Set the connection timeout in milliseconds for the configuration. This is the time allowed for the underlying HTTP client to connect - * to the LaunchDarkly server. The default is 2000 ms. - *

    Both this method and {@link #connectTimeout(int) connectTimeoutMillis} affect the same property internally.

    + * Deprecated method for setting the connection timeout. * * @param connectTimeoutMillis the connection timeout in milliseconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeoutMillis(int)}. */ public Builder connectTimeoutMillis(int connectTimeoutMillis) { - this.connectTimeout = connectTimeoutMillis; - this.connectTimeoutUnit = TimeUnit.MILLISECONDS; + this.connectTimeoutMillis = connectTimeoutMillis; return this; } /** - * Set the socket timeout in milliseconds for the configuration. This is the number of milliseconds between successive packets that the - * client will tolerate before flagging an error. The default is 10,000 milliseconds. - *

    Both this method and {@link #socketTimeout(int) socketTimeoutMillis} affect the same property internally.

    + * Deprecated method for setting the socket read timeout. * * @param socketTimeoutMillis the socket timeout in milliseconds * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#socketTimeoutMillis(int)}. */ public Builder socketTimeoutMillis(int socketTimeoutMillis) { - this.socketTimeout = socketTimeoutMillis; - this.socketTimeoutUnit = TimeUnit.MILLISECONDS; + this.socketTimeoutMillis = socketTimeoutMillis; return this; } /** - * Deprecated method for setting the event buffer flush interval + * Deprecated method for setting the event buffer flush interval. * * @param flushInterval the flush interval in seconds * @return the builder @@ -448,8 +438,9 @@ public Builder capacity(int capacity) { } /** - * Set the host to use as an HTTP proxy for making connections to LaunchDarkly. If this is not set, but - * {@link #proxyPort(int)} is specified, this will default to localhost. + * Deprecated method for specifying an HTTP proxy. + * + * If this is not set, but {@link #proxyPort(int)} is specified, this will default to localhost. *

    * If neither {@link #proxyHost(String)} nor {@link #proxyPort(int)} are specified, * a proxy will not be used, and {@link LDClient} will connect to LaunchDarkly directly. @@ -457,6 +448,7 @@ public Builder capacity(int capacity) { * * @param host the proxy hostname * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyHostAndPort(String, int)}. */ public Builder proxyHost(String host) { this.proxyHost = host; @@ -464,10 +456,11 @@ public Builder proxyHost(String host) { } /** - * Set the port to use for an HTTP proxy for making connections to LaunchDarkly. This is required for proxied HTTP connections. + * Deprecated method for specifying the port of an HTTP proxy. * * @param port the proxy port * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyHostAndPort(String, int)}. */ public Builder proxyPort(int port) { this.proxyPort = port; @@ -475,11 +468,12 @@ public Builder proxyPort(int port) { } /** - * Sets the username for the optional HTTP proxy. Only used when {@link LDConfig.Builder#proxyPassword(String)} - * is also called. + * Deprecated method for specifying HTTP proxy authorization credentials. * * @param username the proxy username * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyAuth(com.launchdarkly.client.interfaces.HttpAuthentication)} + * and {@link Components#httpBasicAuthentication(String, String)}. */ public Builder proxyUsername(String username) { this.proxyUsername = username; @@ -487,11 +481,12 @@ public Builder proxyUsername(String username) { } /** - * Sets the password for the optional HTTP proxy. Only used when {@link LDConfig.Builder#proxyUsername(String)} - * is also called. + * Deprecated method for specifying HTTP proxy authorization credentials. * * @param password the proxy password * @return the builder + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyAuth(com.launchdarkly.client.interfaces.HttpAuthentication)} + * and {@link Components#httpBasicAuthentication(String, String)}. */ public Builder proxyPassword(String password) { this.proxyPassword = password; @@ -499,13 +494,14 @@ public Builder proxyPassword(String password) { } /** - * Sets the {@link SSLSocketFactory} used to secure HTTPS connections to LaunchDarkly. + * Deprecated method for specifying a custom SSL socket factory and certificate trust manager. * * @param sslSocketFactory the SSL socket factory * @param trustManager the trust manager * @return the builder * * @since 4.7.0 + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#sslSocketFactory(SSLSocketFactory, X509TrustManager)}. */ public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { this.sslSocketFactory = sslSocketFactory; @@ -712,13 +708,12 @@ public Builder diagnosticOptOut(boolean diagnosticOptOut) { } /** - * For use by wrapper libraries to set an identifying name for the wrapper being used. This will be included in a - * header during requests to the LaunchDarkly servers to allow recording metrics on the usage of - * these wrapper libraries. + * Deprecated method of specifing a wrapper library identifier. * * @param wrapperName an identifying name for the wrapper library * @return the builder * @since 4.12.0 + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#wrapper(String, String)}. */ public Builder wrapperName(String wrapperName) { this.wrapperName = wrapperName; @@ -726,46 +721,18 @@ public Builder wrapperName(String wrapperName) { } /** - * For use by wrapper libraries to report the version of the library in use. If {@link #wrapperName(String)} is not - * set, this field will be ignored. Otherwise the version string will be included in a header along - * with the wrapperName during requests to the LaunchDarkly servers. + * Deprecated method of specifing a wrapper library identifier. * - * @param wrapperVersion Version string for the wrapper library + * @param wrapperVersion version string for the wrapper library * @return the builder * @since 4.12.0 + * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#wrapper(String, String)}. */ public Builder wrapperVersion(String wrapperVersion) { this.wrapperVersion = wrapperVersion; return this; } - // returns null if none of the proxy bits were configured. Minimum required part: port. - Proxy proxy() { - if (this.proxyPort == -1) { - return null; - } else { - return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)); - } - } - - Authenticator proxyAuthenticator() { - if (this.proxyUsername != null && this.proxyPassword != null) { - final String credential = Credentials.basic(proxyUsername, proxyPassword); - return new Authenticator() { - public Request authenticate(Route route, Response response) throws IOException { - if (response.request().header("Proxy-Authorization") != null) { - return null; // Give up, we've already failed to authenticate with the proxy. - } else { - return response.request().newBuilder() - .header("Proxy-Authorization", credential) - .build(); - } - } - }; - } - return null; - } - /** * Builds the configured {@link com.launchdarkly.client.LDConfig} object. * diff --git a/src/main/java/com/launchdarkly/client/StreamProcessor.java b/src/main/java/com/launchdarkly/client/StreamProcessor.java index 61d9cf871..9da59b497 100644 --- a/src/main/java/com/launchdarkly/client/StreamProcessor.java +++ b/src/main/java/com/launchdarkly/client/StreamProcessor.java @@ -3,6 +3,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.SettableFuture; import com.google.gson.JsonElement; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.interfaces.SerializationException; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; diff --git a/src/main/java/com/launchdarkly/client/Util.java b/src/main/java/com/launchdarkly/client/Util.java index 966a3c738..24ada3497 100644 --- a/src/main/java/com/launchdarkly/client/Util.java +++ b/src/main/java/com/launchdarkly/client/Util.java @@ -1,16 +1,26 @@ package com.launchdarkly.client; +import com.google.common.base.Function; import com.google.gson.JsonPrimitive; +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.value.LDValue; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; +import java.io.IOException; import java.util.concurrent.TimeUnit; +import static com.google.common.collect.Iterables.transform; + +import okhttp3.Authenticator; import okhttp3.ConnectionPool; import okhttp3.Headers; import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.Route; class Util { /** @@ -37,12 +47,8 @@ static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration con .add("Authorization", sdkKey) .add("User-Agent", "JavaClient/" + LDClient.CLIENT_VERSION); - if (config.wrapperName != null) { - String wrapperVersion = ""; - if (config.wrapperVersion != null) { - wrapperVersion = "/" + config.wrapperVersion; - } - builder.add("X-LaunchDarkly-Wrapper", config.wrapperName + wrapperVersion); + if (config.getWrapperIdentifier() != null) { + builder.add("X-LaunchDarkly-Wrapper", config.getWrapperIdentifier()); } return builder; @@ -50,23 +56,48 @@ static Headers.Builder getHeadersBuilderFor(String sdkKey, HttpConfiguration con static void configureHttpClientBuilder(HttpConfiguration config, OkHttpClient.Builder builder) { builder.connectionPool(new ConnectionPool(5, 5, TimeUnit.SECONDS)) - .connectTimeout(config.connectTimeout, config.connectTimeoutUnit) - .readTimeout(config.socketTimeout, config.socketTimeoutUnit) - .writeTimeout(config.socketTimeout, config.socketTimeoutUnit) + .connectTimeout(config.getConnectTimeoutMillis(), TimeUnit.MILLISECONDS) + .readTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) + .writeTimeout(config.getSocketTimeoutMillis(), TimeUnit.MILLISECONDS) .retryOnConnectionFailure(false); // we will implement our own retry logic - if (config.sslSocketFactory != null) { - builder.sslSocketFactory(config.sslSocketFactory, config.trustManager); + if (config.getSslSocketFactory() != null) { + builder.sslSocketFactory(config.getSslSocketFactory(), config.getTrustManager()); } - if (config.proxy != null) { - builder.proxy(config.proxy); - if (config.proxyAuthenticator != null) { - builder.proxyAuthenticator(config.proxyAuthenticator); + if (config.getProxy() != null) { + builder.proxy(config.getProxy()); + if (config.getProxyAuthentication() != null) { + builder.proxyAuthenticator(okhttpAuthenticatorFromHttpAuthStrategy( + config.getProxyAuthentication(), + "Proxy-Authentication", + "Proxy-Authorization" + )); } } } + static final Authenticator okhttpAuthenticatorFromHttpAuthStrategy(final HttpAuthentication strategy, + final String challengeHeaderName, final String responseHeaderName) { + return new Authenticator() { + public Request authenticate(Route route, Response response) throws IOException { + if (response.request().header(responseHeaderName) != null) { + return null; // Give up, we've already failed to authenticate + } + Iterable challenges = transform(response.challenges(), + new Function() { + public HttpAuthentication.Challenge apply(okhttp3.Challenge c) { + return new HttpAuthentication.Challenge(c.scheme(), c.realm()); + } + }); + String credential = strategy.provideAuthorization(challenges); + return response.request().newBuilder() + .header(responseHeaderName, credential) + .build(); + } + }; + } + static void shutdownHttpClient(OkHttpClient client) { if (client.dispatcher() != null) { client.dispatcher().cancelAll(); diff --git a/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java b/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java new file mode 100644 index 000000000..3392f0e9f --- /dev/null +++ b/src/main/java/com/launchdarkly/client/integrations/HttpConfigurationBuilder.java @@ -0,0 +1,139 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.interfaces.HttpAuthentication; +import com.launchdarkly.client.interfaces.HttpConfigurationFactory; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Contains methods for configuring the SDK's networking behavior. + *

    + * If you want to set non-default values for any of these properties, create a builder with + * {@link Components#httpConfiguration()}, change its properties with the methods of this class, + * and pass it to {@link com.launchdarkly.client.LDConfig.Builder#http(HttpConfigurationFactory)}: + *

    
    + *     LDConfig config = new LDConfig.Builder()
    + *         .http(
    + *           Components.httpConfiguration()
    + *             .connectTimeoutMillis(3000)
    + *             .proxyHostAndPort("my-proxy", 8080)
    + *          )
    + *         .build();
    + * 
    + *

    + * These properties will override any equivalent deprecated properties that were set with {@code LDConfig.Builder}, + * such as {@link com.launchdarkly.client.LDConfig.Builder#connectTimeoutMillis(int)}. + *

    + * Note that this class is abstract; the actual implementation is created by calling {@link Components#httpConfiguration()}. + * + * @since 4.13.0 + */ +public abstract class HttpConfigurationBuilder implements HttpConfigurationFactory { + /** + * The default value for {@link #connectTimeoutMillis(int)}. + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 2000; + + /** + * The default value for {@link #socketTimeoutMillis(int)}. + */ + public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 10000; + + protected int connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT_MILLIS; + protected HttpAuthentication proxyAuth; + protected String proxyHost; + protected int proxyPort; + protected int socketTimeoutMillis = DEFAULT_SOCKET_TIMEOUT_MILLIS; + protected SSLSocketFactory sslSocketFactory; + protected X509TrustManager trustManager; + protected String wrapperName; + protected String wrapperVersion; + + /** + * Sets the connection timeout. This is the time allowed for the SDK to make a socket connection to + * any of the LaunchDarkly services. + *

    + * The default is {@link #DEFAULT_CONNECT_TIMEOUT_MILLIS}. + * + * @param connectTimeoutMillis the connection timeout, in milliseconds + * @return the builder + */ + public HttpConfigurationBuilder connectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + return this; + } + + /** + * Sets an HTTP proxy for making connections to LaunchDarkly. + * + * @param host the proxy hostname + * @param port the proxy port + * @return the builder + */ + public HttpConfigurationBuilder proxyHostAndPort(String host, int port) { + this.proxyHost = host; + this.proxyPort = port; + return this; + } + + /** + * Sets an authentication strategy for use with an HTTP proxy. This has no effect unless a proxy + * was specified with {@link #proxyHostAndPort(String, int)}. + * + * @param strategy the authentication strategy + * @return the builder + */ + public HttpConfigurationBuilder proxyAuth(HttpAuthentication strategy) { + this.proxyAuth = strategy; + return this; + } + + /** + * Sets the socket timeout. This is the amount of time without receiving data on a connection that the + * SDK will tolerate before signaling an error. This does not apply to the streaming connection + * used by {@link com.launchdarkly.client.Components#streamingDataSource()}, which has its own + * non-configurable read timeout based on the expected behavior of the LaunchDarkly streaming service. + *

    + * The default is {@link #DEFAULT_SOCKET_TIMEOUT_MILLIS}. + * + * @param socketTimeoutMillis the socket timeout, in milliseconds + * @return the builder + */ + public HttpConfigurationBuilder socketTimeoutMillis(int socketTimeoutMillis) { + this.socketTimeoutMillis = socketTimeoutMillis; + return this; + } + + /** + * Specifies a custom security configuration for HTTPS connections to LaunchDarkly. + *

    + * This uses the standard Java interfaces for configuring secure socket connections and certificate + * verification. + * + * @param sslSocketFactory the SSL socket factory + * @param trustManager the trust manager + * @return the builder + */ + public HttpConfigurationBuilder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { + this.sslSocketFactory = sslSocketFactory; + this.trustManager = trustManager; + return this; + } + + /** + * For use by wrapper libraries to set an identifying name for the wrapper being used. This will be included in a + * header during requests to the LaunchDarkly servers to allow recording metrics on the usage of + * these wrapper libraries. + * + * @param wrapperName an identifying name for the wrapper library + * @param wrapperVersion version string for the wrapper library + * @return the builder + */ + public HttpConfigurationBuilder wrapper(String wrapperName, String wrapperVersion) { + this.wrapperName = wrapperName; + this.wrapperVersion = wrapperVersion; + return this; + } +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java b/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java new file mode 100644 index 000000000..879a201ec --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/HttpAuthentication.java @@ -0,0 +1,52 @@ +package com.launchdarkly.client.interfaces; + +/** + * Represents a supported method of HTTP authentication, including proxy authentication. + * + * @since 4.13.0 + */ +public interface HttpAuthentication { + /** + * Computes the {@code Authorization} or {@code Proxy-Authorization} header for an authentication challenge. + * + * @param challenges the authentication challenges provided by the server, if any (may be empty if this is + * pre-emptive authentication) + * @return the value for the authorization request header + */ + String provideAuthorization(Iterable challenges); + + /** + * Properties of an HTTP authentication challenge. + */ + public static class Challenge { + private final String scheme; + private final String realm; + + /** + * Constructs an instance. + * + * @param scheme the authentication scheme + * @param realm the authentication realm or null + */ + public Challenge(String scheme, String realm) { + this.scheme = scheme; + this.realm = realm; + } + + /** + * The authentication scheme, such as "basic". + * @return the authentication scheme + */ + public String getScheme() { + return scheme; + } + + /** + * The authentication realm, if any. + * @return the authentication realm or null + */ + public String getRealm() { + return realm; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java b/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java new file mode 100644 index 000000000..9d0cb31df --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/HttpConfiguration.java @@ -0,0 +1,73 @@ +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.integrations.HttpConfigurationBuilder; + +import java.net.Proxy; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +/** + * Encapsulates top-level HTTP configuration that applies to all SDK components. + *

    + * Use {@link HttpConfigurationBuilder} to construct an instance. + * + * @since 4.13.0 + */ +public interface HttpConfiguration { + /** + * The connection timeout. This is the time allowed for the underlying HTTP client to connect + * to the LaunchDarkly server. + * + * @return the connection timeout, in milliseconds + */ + int getConnectTimeoutMillis(); + + /** + * The proxy configuration, if any. + * + * @return a {@link Proxy} instance or null + */ + Proxy getProxy(); + + /** + * The authentication method to use for a proxy, if any. Ignored if {@link #getProxy()} is null. + * + * @return an {@link HttpAuthentication} implementation or null + */ + HttpAuthentication getProxyAuthentication(); + + /** + * The socket timeout. This is the amount of time without receiving data on a connection that the + * SDK will tolerate before signaling an error. This does not apply to the streaming connection + * used by {@link com.launchdarkly.client.Components#streamingDataSource()}, which has its own + * non-configurable read timeout based on the expected behavior of the LaunchDarkly streaming service. + * + * @return the socket timeout, in milliseconds + */ + int getSocketTimeoutMillis(); + + /** + * The configured socket factory for secure connections. + * + * @return a SSLSocketFactory or null + */ + SSLSocketFactory getSslSocketFactory(); + + /** + * The configured trust manager for secure connections, if custom certificate verification is needed. + * + * @return an X509TrustManager or null + */ + X509TrustManager getTrustManager(); + + /** + * An optional identifier used by wrapper libraries to indicate what wrapper is being used. + * + * This allows LaunchDarkly to gather metrics on the usage of wrappers that are based on the Java SDK. + * It is part of {@link HttpConfiguration} because it is included in HTTP headers. + * + * @return a wrapper identifier string or null + */ + String getWrapperIdentifier(); +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java b/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java new file mode 100644 index 000000000..ade4a5d48 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/HttpConfigurationFactory.java @@ -0,0 +1,16 @@ +package com.launchdarkly.client.interfaces; + +/** + * Interface for a factory that creates an {@link HttpConfiguration}. + * + * @see com.launchdarkly.client.Components#httpConfiguration() + * @see com.launchdarkly.client.LDConfig.Builder#http(HttpConfigurationFactory) + * @since 4.13.0 + */ +public interface HttpConfigurationFactory { + /** + * Creates the configuration object. + * @return an {@link HttpConfiguration} + */ + public HttpConfiguration createHttpConfiguration(); +} diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index e652c3f7b..3ec9aab0b 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -819,8 +819,7 @@ public void wrapperHeaderSentWhenSet() throws Exception { try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { LDConfig config = new LDConfig.Builder() .diagnosticOptOut(true) - .wrapperName("Scala") - .wrapperVersion("0.1.0") + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), config)) { Event e = EventFactory.DEFAULT.newIdentifyEvent(user); @@ -832,24 +831,6 @@ public void wrapperHeaderSentWhenSet() throws Exception { } } - @Test - public void wrapperHeaderSentWithoutVersion() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = new LDConfig.Builder() - .diagnosticOptOut(true) - .wrapperName("Scala") - .build(); - - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), config)) { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - } - - RecordedRequest req = server.takeRequest(); - assertThat(req.getHeader("X-LaunchDarkly-Wrapper"), equalTo("Scala")); - } - } - @Test public void http400ErrorIsRecoverable() throws Exception { testRecoverableHttpError(400); @@ -907,7 +888,8 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { EventProcessorBuilder ec = sendEvents().baseURI(serverWithCert.uri()); LDConfig config = new LDConfig.Builder() - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (DefaultEventProcessor ep = makeEventProcessor(ec, config)) { diff --git a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java index 20fe513ef..81f847bf0 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticEventTest.java @@ -89,21 +89,12 @@ public void testDefaultDiagnosticConfiguration() { @Test public void testCustomDiagnosticConfigurationGeneralProperties() { LDConfig ldConfig = new LDConfig.Builder() - .connectTimeout(5) - .socketTimeout(20) .startWaitMillis(10_000) - .proxyPort(1234) - .proxyUsername("username") - .proxyPassword("password") .build(); LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); LDValue expected = expectedDefaultProperties() - .put("connectTimeoutMillis", 5_000) - .put("socketTimeoutMillis", 20_000) .put("startWaitMillis", 10_000) - .put("usingProxy", true) - .put("usingProxyAuthenticator", true) .build(); assertEquals(expected, diagnosticJson); @@ -209,6 +200,29 @@ public void testCustomDiagnosticConfigurationForOffline() { assertEquals(expected, diagnosticJson); } + @Test + public void testCustomDiagnosticConfigurationHttpProperties() { + LDConfig ldConfig = new LDConfig.Builder() + .http( + Components.httpConfiguration() + .connectTimeoutMillis(5_000) + .socketTimeoutMillis(20_000) + .proxyHostAndPort("localhost", 1234) + .proxyAuth(Components.httpBasicAuthentication("username", "password")) + ) + .build(); + + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue expected = expectedDefaultProperties() + .put("connectTimeoutMillis", 5_000) + .put("socketTimeoutMillis", 20_000) + .put("usingProxy", true) + .put("usingProxyAuthenticator", true) + .build(); + + assertEquals(expected, diagnosticJson); + } + @SuppressWarnings("deprecation") @Test public void testCustomDiagnosticConfigurationDeprecatedPropertiesForStreaming() { @@ -263,4 +277,27 @@ public void testCustomDiagnosticConfigurationDeprecatedPropertyForDaemonMode() { assertEquals(expected, diagnosticJson); } + + @SuppressWarnings("deprecation") + @Test + public void testCustomDiagnosticConfigurationDeprecatedHttpProperties() { + LDConfig ldConfig = new LDConfig.Builder() + .connectTimeout(5) + .socketTimeout(20) + .proxyPort(1234) + .proxyUsername("username") + .proxyPassword("password") + .build(); + + LDValue diagnosticJson = DiagnosticEvent.Init.getConfigurationData(ldConfig); + LDValue expected = expectedDefaultProperties() + .put("connectTimeoutMillis", 5_000) + .put("socketTimeoutMillis", 20_000) + .put("usingProxy", true) + .put("usingProxyAuthenticator", true) + .build(); + + assertEquals(expected, diagnosticJson); + } + } diff --git a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java b/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java index ccdd229e0..f0c01184f 100644 --- a/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java +++ b/src/test/java/com/launchdarkly/client/DiagnosticSdkTest.java @@ -8,8 +8,8 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +@SuppressWarnings("javadoc") public class DiagnosticSdkTest { - private static final Gson gson = new Gson(); @Test @@ -24,8 +24,7 @@ public void defaultFieldValues() { @Test public void getsWrapperValuesFromConfig() { LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); assertEquals("java-server-sdk", diagnosticSdk.name); @@ -46,8 +45,7 @@ public void gsonSerializationNoWrapper() { @Test public void gsonSerializationWithWrapper() { LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) .build(); DiagnosticSdk diagnosticSdk = new DiagnosticSdk(config); JsonObject jsonObject = gson.toJsonTree(diagnosticSdk).getAsJsonObject(); diff --git a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java index 6b8ad7646..dacb9b0a2 100644 --- a/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java +++ b/src/test/java/com/launchdarkly/client/FeatureRequestorTest.java @@ -178,7 +178,8 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(resp)) { LDConfig config = new LDConfig.Builder() - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (DefaultFeatureRequestor r = makeRequestor(serverWithCert.server, config)) { @@ -194,8 +195,7 @@ public void httpClientCanUseProxyConfig() throws Exception { try (MockWebServer server = makeStartedServer(jsonResponse(flag1Json))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() - .proxyHost(serverUrl.host()) - .proxyPort(serverUrl.port()) + .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) .build(); try (DefaultFeatureRequestor r = new DefaultFeatureRequestor(sdkKey, config.httpConfig, fakeBaseUri, true)) { diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index 22e897712..355090516 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -70,7 +70,8 @@ public void clientStartsInPollingModeWithSelfSignedCert() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(basePollingConfig(serverWithCert.server)) .events(noEvents()) - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (LDClient client = new LDClient(sdkKey, config)) { @@ -126,7 +127,8 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { LDConfig config = new LDConfig.Builder() .dataSource(baseStreamingConfig(serverWithCert.server)) .events(noEvents()) - .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) + // allows us to trust the self-signed cert .build(); try (LDClient client = new LDClient(sdkKey, config)) { diff --git a/src/test/java/com/launchdarkly/client/LDConfigTest.java b/src/test/java/com/launchdarkly/client/LDConfigTest.java index 0f9d074f1..3e89c7a5a 100644 --- a/src/test/java/com/launchdarkly/client/LDConfigTest.java +++ b/src/test/java/com/launchdarkly/client/LDConfigTest.java @@ -1,128 +1,218 @@ package com.launchdarkly.client; +import com.launchdarkly.client.integrations.HttpConfigurationBuilderTest; +import com.launchdarkly.client.interfaces.HttpConfiguration; + import org.junit.Test; import java.net.InetSocketAddress; import java.net.Proxy; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + 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.assertSame; import static org.junit.Assert.assertTrue; @SuppressWarnings("javadoc") public class LDConfigTest { + @SuppressWarnings("deprecation") + @Test + public void testMinimumPollingIntervalIsEnforcedProperly(){ + LDConfig config = new LDConfig.Builder().pollingIntervalMillis(10L).build(); + assertEquals(30000L, config.deprecatedPollingIntervalMillis); + } + + @SuppressWarnings("deprecation") + @Test + public void testPollingIntervalIsEnforcedProperly(){ + LDConfig config = new LDConfig.Builder().pollingIntervalMillis(30001L).build(); + assertEquals(30001L, config.deprecatedPollingIntervalMillis); + } + + @Test + public void testSendEventsDefaultsToTrue() { + LDConfig config = new LDConfig.Builder().build(); + assertEquals(true, config.deprecatedSendEvents); + } + + @SuppressWarnings("deprecation") + @Test + public void testSendEventsCanBeSetToFalse() { + LDConfig config = new LDConfig.Builder().sendEvents(false).build(); + assertEquals(false, config.deprecatedSendEvents); + } + + @Test + public void testDefaultDiagnosticOptOut() { + LDConfig config = new LDConfig.Builder().build(); + assertFalse(config.diagnosticOptOut); + } + + @Test + public void testDiagnosticOptOut() { + LDConfig config = new LDConfig.Builder().diagnosticOptOut(true).build(); + assertTrue(config.diagnosticOptOut); + } + + @Test + public void testWrapperNotConfigured() { + LDConfig config = new LDConfig.Builder().build(); + assertNull(config.httpConfig.getWrapperIdentifier()); + } + + @Test + public void testWrapperNameOnly() { + LDConfig config = new LDConfig.Builder() + .http( + Components.httpConfiguration() + .wrapper("Scala", null) + ) + .build(); + assertEquals("Scala", config.httpConfig.getWrapperIdentifier()); + } + @Test - public void testNoProxyConfigured() { + public void testWrapperWithVersion() { + LDConfig config = new LDConfig.Builder() + .http( + Components.httpConfiguration() + .wrapper("Scala", "0.1.0") + ) + .build(); + assertEquals("Scala/0.1.0", config.httpConfig.getWrapperIdentifier()); + } + + @Test + public void testHttpDefaults() { LDConfig config = new LDConfig.Builder().build(); - assertNull(config.httpConfig.proxy); - assertNull(config.httpConfig.proxyAuthenticator); + HttpConfiguration hc = config.httpConfig; + HttpConfiguration defaults = Components.httpConfiguration().createHttpConfiguration(); + assertEquals(defaults.getConnectTimeoutMillis(), hc.getConnectTimeoutMillis()); + assertNull(hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + assertEquals(defaults.getSocketTimeoutMillis(), hc.getSocketTimeoutMillis()); + assertNull(hc.getSslSocketFactory()); + assertNull(hc.getTrustManager()); + assertNull(hc.getWrapperIdentifier()); } + @SuppressWarnings("deprecation") @Test - public void testOnlyProxyHostConfiguredIsNull() { + public void testDeprecatedHttpConnectTimeout() { + LDConfig config = new LDConfig.Builder().connectTimeoutMillis(999).build(); + assertEquals(999, config.httpConfig.getConnectTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpConnectTimeoutSeconds() { + LDConfig config = new LDConfig.Builder().connectTimeout(999).build(); + assertEquals(999000, config.httpConfig.getConnectTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpSocketTimeout() { + LDConfig config = new LDConfig.Builder().socketTimeoutMillis(999).build(); + assertEquals(999, config.httpConfig.getSocketTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpSocketTimeoutSeconds() { + LDConfig config = new LDConfig.Builder().socketTimeout(999).build(); + assertEquals(999000, config.httpConfig.getSocketTimeoutMillis()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedHttpOnlyProxyHostConfiguredIsNull() { LDConfig config = new LDConfig.Builder().proxyHost("bla").build(); - assertNull(config.httpConfig.proxy); + assertNull(config.httpConfig.getProxy()); } + @SuppressWarnings("deprecation") @Test - public void testOnlyProxyPortConfiguredHasPortAndDefaultHost() { + public void testDeprecatedHttpOnlyProxyPortConfiguredHasPortAndDefaultHost() { LDConfig config = new LDConfig.Builder().proxyPort(1234).build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.proxy); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost", 1234)), config.httpConfig.getProxy()); } + + @SuppressWarnings("deprecation") @Test - public void testProxy() { + public void testDeprecatedHttpProxy() { LDConfig config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) .build(); - assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost2", 4444)), config.httpConfig.proxy); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("localhost2", 4444)), config.httpConfig.getProxy()); } + @SuppressWarnings("deprecation") @Test - public void testProxyAuth() { + public void testDeprecatedHttpProxyAuth() { LDConfig config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) - .proxyUsername("proxyUser") - .proxyPassword("proxyPassword") + .proxyUsername("user") + .proxyPassword("pass") .build(); - assertNotNull(config.httpConfig.proxy); - assertNotNull(config.httpConfig.proxyAuthenticator); + assertNotNull(config.httpConfig.getProxy()); + assertNotNull(config.httpConfig.getProxyAuthentication()); + assertEquals("Basic dXNlcjpwYXNz", config.httpConfig.getProxyAuthentication().provideAuthorization(null)); } + @SuppressWarnings("deprecation") @Test - public void testProxyAuthPartialConfig() { + public void testDeprecatedHttpProxyAuthPartialConfig() { LDConfig config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) .proxyUsername("proxyUser") .build(); - assertNotNull(config.httpConfig.proxy); - assertNull(config.httpConfig.proxyAuthenticator); + assertNotNull(config.httpConfig.getProxy()); + assertNull(config.httpConfig.getProxyAuthentication()); config = new LDConfig.Builder() .proxyHost("localhost2") .proxyPort(4444) .proxyPassword("proxyPassword") .build(); - assertNotNull(config.httpConfig.proxy); - assertNull(config.httpConfig.proxyAuthenticator); + assertNotNull(config.httpConfig.getProxy()); + assertNull(config.httpConfig.getProxyAuthentication()); } @SuppressWarnings("deprecation") @Test - public void testMinimumPollingIntervalIsEnforcedProperly(){ - LDConfig config = new LDConfig.Builder().pollingIntervalMillis(10L).build(); - assertEquals(30000L, config.deprecatedPollingIntervalMillis); + public void testDeprecatedHttpSslOptions() { + SSLSocketFactory sf = new HttpConfigurationBuilderTest.StubSSLSocketFactory(); + X509TrustManager tm = new HttpConfigurationBuilderTest.StubX509TrustManager(); + LDConfig config = new LDConfig.Builder().sslSocketFactory(sf, tm).build(); + assertSame(sf, config.httpConfig.getSslSocketFactory()); + assertSame(tm, config.httpConfig.getTrustManager()); } @SuppressWarnings("deprecation") @Test - public void testPollingIntervalIsEnforcedProperly(){ - LDConfig config = new LDConfig.Builder().pollingIntervalMillis(30001L).build(); - assertEquals(30001L, config.deprecatedPollingIntervalMillis); - } - - @Test - public void testSendEventsDefaultsToTrue() { - LDConfig config = new LDConfig.Builder().build(); - assertEquals(true, config.deprecatedSendEvents); - } - - @SuppressWarnings("deprecation") - @Test - public void testSendEventsCanBeSetToFalse() { - LDConfig config = new LDConfig.Builder().sendEvents(false).build(); - assertEquals(false, config.deprecatedSendEvents); - } - - @Test - public void testDefaultDiagnosticOptOut() { - LDConfig config = new LDConfig.Builder().build(); - assertFalse(config.diagnosticOptOut); - } - - @Test - public void testDiagnosticOptOut() { - LDConfig config = new LDConfig.Builder().diagnosticOptOut(true).build(); - assertTrue(config.diagnosticOptOut); + public void testDeprecatedHttpWrapperNameOnly() { + LDConfig config = new LDConfig.Builder() + .wrapperName("Scala") + .build(); + assertEquals("Scala", config.httpConfig.getWrapperIdentifier()); } + @SuppressWarnings("deprecation") @Test - public void testWrapperNotConfigured() { - LDConfig config = new LDConfig.Builder().build(); - assertNull(config.httpConfig.wrapperName); - assertNull(config.httpConfig.wrapperVersion); - } - - @Test public void testWrapperConfigured() { + public void testDeprecatedHttpWrapperWithVersion() { LDConfig config = new LDConfig.Builder() .wrapperName("Scala") .wrapperVersion("0.1.0") .build(); - assertEquals("Scala", config.httpConfig.wrapperName); - assertEquals("0.1.0", config.httpConfig.wrapperVersion); + assertEquals("Scala/0.1.0", config.httpConfig.getWrapperIdentifier()); } } \ No newline at end of file diff --git a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java index d204c253a..8858e6a3f 100644 --- a/src/test/java/com/launchdarkly/client/StreamProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/StreamProcessorTest.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.eventsource.ConnectionErrorHandler; import com.launchdarkly.eventsource.EventHandler; import com.launchdarkly.eventsource.EventSource; @@ -159,9 +160,8 @@ public void headersHaveAccept() { @Test public void headersHaveWrapperWhenSet() { LDConfig config = new LDConfig.Builder() - .wrapperName("Scala") - .wrapperVersion("0.1.0") - .build(); + .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) + .build(); createStreamProcessor(config, STREAM_URI).start(); assertEquals("Scala/0.1.0", headers.get("X-LaunchDarkly-Wrapper")); } @@ -655,7 +655,8 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { server.server.enqueue(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA)); LDConfig config = new LDConfig.Builder() - .sslSocketFactory(server.socketFactory, server.trustManager) // allows us to trust the self-signed cert + .http(Components.httpConfiguration().sslSocketFactory(server.socketFactory, server.trustManager)) + // allows us to trust the self-signed cert .build(); try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, server.uri())) { @@ -675,8 +676,7 @@ public void httpClientCanUseProxyConfig() throws Exception { try (MockWebServer server = makeStartedServer(eventStreamResponse(STREAM_RESPONSE_WITH_EMPTY_DATA))) { HttpUrl serverUrl = server.url("/"); LDConfig config = new LDConfig.Builder() - .proxyHost(serverUrl.host()) - .proxyPort(serverUrl.port()) + .http(Components.httpConfiguration().proxyHostAndPort(serverUrl.host(), serverUrl.port())) .build(); try (StreamProcessor sp = createStreamProcessorWithRealHttp(config, fakeStreamUri)) { diff --git a/src/test/java/com/launchdarkly/client/UtilTest.java b/src/test/java/com/launchdarkly/client/UtilTest.java index 400660247..84744dc9c 100644 --- a/src/test/java/com/launchdarkly/client/UtilTest.java +++ b/src/test/java/com/launchdarkly/client/UtilTest.java @@ -82,21 +82,8 @@ public void testDateTimeConversionInvalidString() { } @Test - public void testConnectTimeoutSpecifiedInSeconds() { - LDConfig config = new LDConfig.Builder().connectTimeout(3).build(); - OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config.httpConfig, httpBuilder); - OkHttpClient httpClient = httpBuilder.build(); - try { - assertEquals(3000, httpClient.connectTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } - - @Test - public void testConnectTimeoutSpecifiedInMilliseconds() { - LDConfig config = new LDConfig.Builder().connectTimeoutMillis(3000).build(); + public void testConnectTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().connectTimeoutMillis(3000)).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); @@ -108,21 +95,8 @@ public void testConnectTimeoutSpecifiedInMilliseconds() { } @Test - public void testSocketTimeoutSpecifiedInSeconds() { - LDConfig config = new LDConfig.Builder().socketTimeout(3).build(); - OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(config.httpConfig, httpBuilder); - OkHttpClient httpClient = httpBuilder.build(); - try { - assertEquals(3000, httpClient.readTimeoutMillis()); - } finally { - shutdownHttpClient(httpClient); - } - } - - @Test - public void testSocketTimeoutSpecifiedInMilliseconds() { - LDConfig config = new LDConfig.Builder().socketTimeoutMillis(3000).build(); + public void testSocketTimeout() { + LDConfig config = new LDConfig.Builder().http(Components.httpConfiguration().socketTimeoutMillis(3000)).build(); OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(config.httpConfig, httpBuilder); OkHttpClient httpClient = httpBuilder.build(); diff --git a/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java b/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java new file mode 100644 index 000000000..b9a254866 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/integrations/HttpConfigurationBuilderTest.java @@ -0,0 +1,141 @@ +package com.launchdarkly.client.integrations; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.interfaces.HttpConfiguration; + +import org.junit.Test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Socket; +import java.net.UnknownHostException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.X509TrustManager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +@SuppressWarnings("javadoc") +public class HttpConfigurationBuilderTest { + @Test + public void testDefaults() { + HttpConfiguration hc = Components.httpConfiguration().createHttpConfiguration(); + assertEquals(HttpConfigurationBuilder.DEFAULT_CONNECT_TIMEOUT_MILLIS, hc.getConnectTimeoutMillis()); + assertNull(hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + assertEquals(HttpConfigurationBuilder.DEFAULT_SOCKET_TIMEOUT_MILLIS, hc.getSocketTimeoutMillis()); + assertNull(hc.getSslSocketFactory()); + assertNull(hc.getTrustManager()); + assertNull(hc.getWrapperIdentifier()); + } + + @Test + public void testConnectTimeout() { + HttpConfiguration hc = Components.httpConfiguration() + .connectTimeoutMillis(999) + .createHttpConfiguration(); + assertEquals(999, hc.getConnectTimeoutMillis()); + } + + @Test + public void testProxy() { + HttpConfiguration hc = Components.httpConfiguration() + .proxyHostAndPort("my-proxy", 1234) + .createHttpConfiguration(); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("my-proxy", 1234)), hc.getProxy()); + assertNull(hc.getProxyAuthentication()); + } + + @Test + public void testProxyBasicAuth() { + HttpConfiguration hc = Components.httpConfiguration() + .proxyHostAndPort("my-proxy", 1234) + .proxyAuth(Components.httpBasicAuthentication("user", "pass")) + .createHttpConfiguration(); + assertEquals(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("my-proxy", 1234)), hc.getProxy()); + assertNotNull(hc.getProxyAuthentication()); + assertEquals("Basic dXNlcjpwYXNz", hc.getProxyAuthentication().provideAuthorization(null)); + } + + @Test + public void testSocketTimeout() { + HttpConfiguration hc = Components.httpConfiguration() + .socketTimeoutMillis(999) + .createHttpConfiguration(); + assertEquals(999, hc.getSocketTimeoutMillis()); + } + + @Test + public void testSslOptions() { + SSLSocketFactory sf = new StubSSLSocketFactory(); + X509TrustManager tm = new StubX509TrustManager(); + HttpConfiguration hc = Components.httpConfiguration().sslSocketFactory(sf, tm).createHttpConfiguration(); + assertSame(sf, hc.getSslSocketFactory()); + assertSame(tm, hc.getTrustManager()); + } + + @Test + public void testWrapperNameOnly() { + HttpConfiguration hc = Components.httpConfiguration() + .wrapper("Scala", null) + .createHttpConfiguration(); + assertEquals("Scala", hc.getWrapperIdentifier()); + } + + @Test + public void testWrapperWithVersion() { + HttpConfiguration hc = Components.httpConfiguration() + .wrapper("Scala", "0.1.0") + .createHttpConfiguration(); + assertEquals("Scala/0.1.0", hc.getWrapperIdentifier()); + } + + public static class StubSSLSocketFactory extends SSLSocketFactory { + public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) + throws IOException { + return null; + } + + public Socket createSocket(String host, int port, InetAddress localHost, int localPort) + throws IOException, UnknownHostException { + return null; + } + + public Socket createSocket(InetAddress host, int port) throws IOException { + return null; + } + + public Socket createSocket(String host, int port) throws IOException, UnknownHostException { + return null; + } + + public String[] getSupportedCipherSuites() { + return null; + } + + public String[] getDefaultCipherSuites() { + return null; + } + + public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { + return null; + } + } + + public static class StubX509TrustManager implements X509TrustManager { + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {} + + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {} + } +} From e076f779c67eb19cc221d26ce41e8a8c43e57e25 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Thu, 16 Apr 2020 18:13:06 -0700 Subject: [PATCH 293/327] deprecated methods with an annotation, not just with javadoc --- src/main/java/com/launchdarkly/client/LDConfig.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/launchdarkly/client/LDConfig.java b/src/main/java/com/launchdarkly/client/LDConfig.java index 75e63fd67..2a1be1b35 100644 --- a/src/main/java/com/launchdarkly/client/LDConfig.java +++ b/src/main/java/com/launchdarkly/client/LDConfig.java @@ -292,6 +292,7 @@ public Builder events(EventProcessorFactory factory) { * @since 4.0.0 * @deprecated Use {@link #events(EventProcessorFactory)}. */ + @Deprecated public Builder eventProcessorFactory(EventProcessorFactory factory) { this.eventProcessorFactory = factory; return this; @@ -372,6 +373,7 @@ public Builder http(HttpConfigurationFactory factory) { * @return the builder * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeoutMillis(int)}. */ + @Deprecated public Builder connectTimeout(int connectTimeout) { return connectTimeoutMillis(connectTimeout * 1000); } @@ -383,6 +385,7 @@ public Builder connectTimeout(int connectTimeout) { * @return the builder * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#socketTimeoutMillis(int)}. */ + @Deprecated public Builder socketTimeout(int socketTimeout) { return socketTimeoutMillis(socketTimeout * 1000); } @@ -394,6 +397,7 @@ public Builder socketTimeout(int socketTimeout) { * @return the builder * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#connectTimeoutMillis(int)}. */ + @Deprecated public Builder connectTimeoutMillis(int connectTimeoutMillis) { this.connectTimeoutMillis = connectTimeoutMillis; return this; @@ -406,6 +410,7 @@ public Builder connectTimeoutMillis(int connectTimeoutMillis) { * @return the builder * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#socketTimeoutMillis(int)}. */ + @Deprecated public Builder socketTimeoutMillis(int socketTimeoutMillis) { this.socketTimeoutMillis = socketTimeoutMillis; return this; @@ -450,6 +455,7 @@ public Builder capacity(int capacity) { * @return the builder * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyHostAndPort(String, int)}. */ + @Deprecated public Builder proxyHost(String host) { this.proxyHost = host; return this; @@ -462,6 +468,7 @@ public Builder proxyHost(String host) { * @return the builder * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyHostAndPort(String, int)}. */ + @Deprecated public Builder proxyPort(int port) { this.proxyPort = port; return this; @@ -475,6 +482,7 @@ public Builder proxyPort(int port) { * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyAuth(com.launchdarkly.client.interfaces.HttpAuthentication)} * and {@link Components#httpBasicAuthentication(String, String)}. */ + @Deprecated public Builder proxyUsername(String username) { this.proxyUsername = username; return this; @@ -488,6 +496,7 @@ public Builder proxyUsername(String username) { * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#proxyAuth(com.launchdarkly.client.interfaces.HttpAuthentication)} * and {@link Components#httpBasicAuthentication(String, String)}. */ + @Deprecated public Builder proxyPassword(String password) { this.proxyPassword = password; return this; @@ -503,6 +512,7 @@ public Builder proxyPassword(String password) { * @since 4.7.0 * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#sslSocketFactory(SSLSocketFactory, X509TrustManager)}. */ + @Deprecated public Builder sslSocketFactory(SSLSocketFactory sslSocketFactory, X509TrustManager trustManager) { this.sslSocketFactory = sslSocketFactory; this.trustManager = trustManager; @@ -715,6 +725,7 @@ public Builder diagnosticOptOut(boolean diagnosticOptOut) { * @since 4.12.0 * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#wrapper(String, String)}. */ + @Deprecated public Builder wrapperName(String wrapperName) { this.wrapperName = wrapperName; return this; @@ -728,6 +739,7 @@ public Builder wrapperName(String wrapperName) { * @since 4.12.0 * @deprecated Use {@link Components#httpConfiguration()} with {@link HttpConfigurationBuilder#wrapper(String, String)}. */ + @Deprecated public Builder wrapperVersion(String wrapperVersion) { this.wrapperVersion = wrapperVersion; return this; From ae0a53a1aae794e8e4ebe36fe8d60e7165193ad6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 20 Apr 2020 12:29:17 -0700 Subject: [PATCH 294/327] add getters for all properties on EvaluationReason; deprecate subclasses --- .../launchdarkly/client/EvaluationReason.java | 94 ++++++++++++-- .../client/EventOutputFormatter.java | 25 ++-- .../client/EvaluationReasonTest.java | 116 +++++++++++++++++- 3 files changed, 214 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/EvaluationReason.java b/src/main/java/com/launchdarkly/client/EvaluationReason.java index 1b48346f7..1feedecb5 100644 --- a/src/main/java/com/launchdarkly/client/EvaluationReason.java +++ b/src/main/java/com/launchdarkly/client/EvaluationReason.java @@ -7,9 +7,11 @@ /** * Describes the reason that a flag evaluation produced a particular value. This is returned by * 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. + * possible reasons have their own properties. However, directly referencing the subclasses is + * deprecated; in a future version only the {@link EvaluationReason} base class will be visible, + * and it has getter methods for all of the possible properties. * * @since 4.3.0 */ @@ -101,6 +103,60 @@ public Kind getKind() { return kind; } + + /** + * The index of the rule that was matched (0 for the first rule in the feature flag), + * if the {@code kind} is {@link Kind#RULE_MATCH}. Otherwise this returns -1. + * + * @return the rule index or -1 + */ + public int getRuleIndex() { + return -1; + } + + /** + * The unique identifier of the rule that was matched, if the {@code kind} is + * {@link Kind#RULE_MATCH}. Otherwise {@code null}. + *

    + * Unlike the rule index, this identifier will not change if other rules are added or deleted. + * + * @return the rule identifier or null + */ + public String getRuleId() { + return null; + } + + /** + * The key of the prerequisite flag that did not return the desired variation, if the + * {@code kind} is {@link Kind#PREREQUISITE_FAILED}. Otherwise {@code null}. + * + * @return the prerequisite flag key or null + */ + public String getPrerequisiteKey() { + return null; + } + + /** + * An enumeration value indicating the general category of error, if the + * {@code kind} is {@link Kind#PREREQUISITE_FAILED}. Otherwise {@code null}. + * + * @return the error kind or null + */ + public ErrorKind getErrorKind() { + return null; + } + + /** + * The exception that caused the error condition, if the {@code kind} is + * {@link EvaluationReason.Kind#ERROR} and the {@code errorKind} is {@link ErrorKind#EXCEPTION}. + * Otherwise {@code null}. + * + * @return the exception instance + * @since 4.11.0 + */ + public Exception getException() { + return null; + } @Override public String toString() { @@ -113,7 +169,7 @@ protected EvaluationReason(Kind kind) } /** - * Returns an instance of {@link Off}. + * Returns an instance whose {@code kind} is {@link Kind#OFF}. * @return a reason object */ public static Off off() { @@ -121,7 +177,7 @@ public static Off off() { } /** - * Returns an instance of {@link TargetMatch}. + * Returns an instance whose {@code kind} is {@link Kind#TARGET_MATCH}. * @return a reason object */ public static TargetMatch targetMatch() { @@ -129,7 +185,7 @@ public static TargetMatch targetMatch() { } /** - * Returns an instance of {@link RuleMatch}. + * Returns an instance whose {@code kind} is {@link Kind#RULE_MATCH}. * @param ruleIndex the rule index * @param ruleId the rule identifier * @return a reason object @@ -139,7 +195,7 @@ public static RuleMatch ruleMatch(int ruleIndex, String ruleId) { } /** - * Returns an instance of {@link PrerequisiteFailed}. + * Returns an instance whose {@code kind} is {@link Kind#PREREQUISITE_FAILED}. * @param prerequisiteKey the flag key of the prerequisite that failed * @return a reason object */ @@ -148,7 +204,7 @@ public static PrerequisiteFailed prerequisiteFailed(String prerequisiteKey) { } /** - * Returns an instance of {@link Fallthrough}. + * Returns an instance whose {@code kind} is {@link Kind#FALLTHROUGH}. * @return a reason object */ public static Fallthrough fallthrough() { @@ -156,7 +212,7 @@ public static Fallthrough fallthrough() { } /** - * Returns an instance of {@link Error}. + * Returns an instance whose {@code kind} is {@link Kind#ERROR}. * @param errorKind describes the type of error * @return a reason object */ @@ -186,7 +242,10 @@ public static Error exception(Exception exception) { * Subclass of {@link EvaluationReason} that indicates that the flag was off and therefore returned * its configured off value. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#OFF} value. */ + @Deprecated public static class Off extends EvaluationReason { private Off() { super(Kind.OFF); @@ -199,7 +258,10 @@ private Off() { * Subclass of {@link EvaluationReason} that indicates that the user key was specifically targeted * for this flag. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#TARGET_MATCH} value. */ + @Deprecated public static class TargetMatch extends EvaluationReason { private TargetMatch() { @@ -212,7 +274,10 @@ private TargetMatch() /** * Subclass of {@link EvaluationReason} that indicates that the user matched one of the flag's rules. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#RULE_MATCH} value. */ + @Deprecated public static class RuleMatch extends EvaluationReason { private final int ruleIndex; private final String ruleId; @@ -227,6 +292,7 @@ private RuleMatch(int ruleIndex, String ruleId) { * The index of the rule that was matched (0 for the first rule in the feature flag). * @return the rule index */ + @Override public int getRuleIndex() { return ruleIndex; } @@ -235,6 +301,7 @@ public int getRuleIndex() { * A unique string identifier for the matched rule, which will not change if other rules are added or deleted. * @return the rule identifier */ + @Override public String getRuleId() { return ruleId; } @@ -263,7 +330,10 @@ 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 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#PREREQUISITE_FAILED} value. */ + @Deprecated public static class PrerequisiteFailed extends EvaluationReason { private final String prerequisiteKey; @@ -276,6 +346,7 @@ private PrerequisiteFailed(String prerequisiteKey) { * The key of the prerequisite flag that did not return the desired variation. * @return the prerequisite flag key */ + @Override public String getPrerequisiteKey() { return prerequisiteKey; } @@ -301,7 +372,10 @@ 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 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#FALLTHROUGH} value. */ + @Deprecated public static class Fallthrough extends EvaluationReason { private Fallthrough() { @@ -314,7 +388,10 @@ private Fallthrough() /** * Subclass of {@link EvaluationReason} that indicates that the flag could not be evaluated. * @since 4.3.0 + * @deprecated This type will be removed in a future version. Use {@link #getKind()} instead and check + * for the {@link Kind#ERROR} value. */ + @Deprecated public static class Error extends EvaluationReason { private final ErrorKind errorKind; private transient final Exception exception; @@ -333,6 +410,7 @@ private Error(ErrorKind errorKind, Exception exception) { * An enumeration value indicating the general category of error. * @return the error kind */ + @Override public ErrorKind getErrorKind() { return errorKind; } diff --git a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java index 268226fed..03ba5a12c 100644 --- a/src/main/java/com/launchdarkly/client/EventOutputFormatter.java +++ b/src/main/java/com/launchdarkly/client/EventOutputFormatter.java @@ -206,22 +206,25 @@ private void writeEvaluationReason(String key, EvaluationReason er, JsonWriter j jw.name("kind"); jw.value(er.getKind().name()); - if (er instanceof EvaluationReason.Error) { - EvaluationReason.Error ere = (EvaluationReason.Error)er; + switch (er.getKind()) { + case ERROR: jw.name("errorKind"); - jw.value(ere.getErrorKind().name()); - } else if (er instanceof EvaluationReason.PrerequisiteFailed) { - EvaluationReason.PrerequisiteFailed erpf = (EvaluationReason.PrerequisiteFailed)er; + jw.value(er.getErrorKind().name()); + break; + case PREREQUISITE_FAILED: jw.name("prerequisiteKey"); - jw.value(erpf.getPrerequisiteKey()); - } else if (er instanceof EvaluationReason.RuleMatch) { - EvaluationReason.RuleMatch errm = (EvaluationReason.RuleMatch)er; + jw.value(er.getPrerequisiteKey()); + break; + case RULE_MATCH: jw.name("ruleIndex"); - jw.value(errm.getRuleIndex()); - if (errm.getRuleId() != null) { + jw.value(er.getRuleIndex()); + if (er.getRuleId() != null) { jw.name("ruleId"); - jw.value(errm.getRuleId()); + jw.value(er.getRuleId()); } + break; + default: + break; } jw.endObject(); diff --git a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java index 4745aaaca..f1b409294 100644 --- a/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java +++ b/src/test/java/com/launchdarkly/client/EvaluationReasonTest.java @@ -6,12 +6,124 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @SuppressWarnings("javadoc") public class EvaluationReasonTest { private static final Gson gson = new Gson(); + @Test + public void offProperties() { + EvaluationReason reason = EvaluationReason.off(); + assertEquals(EvaluationReason.Kind.OFF, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void fallthroughProperties() { + EvaluationReason reason = EvaluationReason.fallthrough(); + assertEquals(EvaluationReason.Kind.FALLTHROUGH, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void targetMatchProperties() { + EvaluationReason reason = EvaluationReason.targetMatch(); + assertEquals(EvaluationReason.Kind.TARGET_MATCH, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void ruleMatchProperties() { + EvaluationReason reason = EvaluationReason.ruleMatch(2, "id"); + assertEquals(EvaluationReason.Kind.RULE_MATCH, reason.getKind()); + assertEquals(2, reason.getRuleIndex()); + assertEquals("id", reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void prerequisiteFailedProperties() { + EvaluationReason reason = EvaluationReason.prerequisiteFailed("prereq-key"); + assertEquals(EvaluationReason.Kind.PREREQUISITE_FAILED, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertEquals("prereq-key", reason.getPrerequisiteKey()); + assertNull(reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void errorProperties() { + EvaluationReason reason = EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY); + assertEquals(EvaluationReason.Kind.ERROR, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertEquals(EvaluationReason.ErrorKind.CLIENT_NOT_READY, reason.getErrorKind()); + assertNull(reason.getException()); + } + + @Test + public void exceptionErrorProperties() { + Exception ex = new Exception("sorry"); + EvaluationReason reason = EvaluationReason.exception(ex); + assertEquals(EvaluationReason.Kind.ERROR, reason.getKind()); + assertEquals(-1, reason.getRuleIndex()); + assertNull(reason.getRuleId()); + assertNull(reason.getPrerequisiteKey()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, reason.getErrorKind()); + assertEquals(ex, reason.getException()); + } + + @SuppressWarnings("deprecation") + @Test + public void deprecatedSubclassProperties() { + EvaluationReason ro = EvaluationReason.off(); + assertEquals(EvaluationReason.Off.class, ro.getClass()); + + EvaluationReason rf = EvaluationReason.fallthrough(); + assertEquals(EvaluationReason.Fallthrough.class, rf.getClass()); + + EvaluationReason rtm = EvaluationReason.targetMatch(); + assertEquals(EvaluationReason.TargetMatch.class, rtm.getClass()); + + EvaluationReason rrm = EvaluationReason.ruleMatch(2, "id"); + assertEquals(EvaluationReason.RuleMatch.class, rrm.getClass()); + assertEquals(2, ((EvaluationReason.RuleMatch)rrm).getRuleIndex()); + assertEquals("id", ((EvaluationReason.RuleMatch)rrm).getRuleId()); + + EvaluationReason rpf = EvaluationReason.prerequisiteFailed("prereq-key"); + assertEquals(EvaluationReason.PrerequisiteFailed.class, rpf.getClass()); + assertEquals("prereq-key", ((EvaluationReason.PrerequisiteFailed)rpf).getPrerequisiteKey()); + + EvaluationReason re = EvaluationReason.error(EvaluationReason.ErrorKind.CLIENT_NOT_READY); + assertEquals(EvaluationReason.Error.class, re.getClass()); + assertEquals(EvaluationReason.ErrorKind.CLIENT_NOT_READY, ((EvaluationReason.Error)re).getErrorKind()); + assertNull(((EvaluationReason.Error)re).getException()); + + Exception ex = new Exception("sorry"); + EvaluationReason ree = EvaluationReason.exception(ex); + assertEquals(EvaluationReason.Error.class, ree.getClass()); + assertEquals(EvaluationReason.ErrorKind.EXCEPTION, ((EvaluationReason.Error)ree).getErrorKind()); + assertEquals(ex, ((EvaluationReason.Error)ree).getException()); + } + @Test public void testOffReasonSerialization() { EvaluationReason reason = EvaluationReason.off(); @@ -73,9 +185,9 @@ public void testErrorSerializationWithException() { @Test public void errorInstancesAreReused() { for (EvaluationReason.ErrorKind errorKind: EvaluationReason.ErrorKind.values()) { - EvaluationReason.Error r0 = EvaluationReason.error(errorKind); + EvaluationReason r0 = EvaluationReason.error(errorKind); assertEquals(errorKind, r0.getErrorKind()); - EvaluationReason.Error r1 = EvaluationReason.error(errorKind); + EvaluationReason r1 = EvaluationReason.error(errorKind); assertSame(r0, r1); } } From 1ee6277d6451bfc94a0e888b493cf011dda20b6a Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 21 Apr 2020 15:57:12 -0700 Subject: [PATCH 295/327] don't log exception stacktraces except at debug level --- .../java/com/launchdarkly/client/DefaultEventProcessor.java | 3 ++- src/main/java/com/launchdarkly/client/LDClient.java | 3 ++- 2 files 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 0efa8e46e..a82890e78 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -530,7 +530,8 @@ private static void postJson(OkHttpClient httpClient, Headers headers, String js } break; } catch (IOException e) { - logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: " + request.url(), e); + logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: {} ({})", request.url(), e.toString()); + logger.debug(e.toString(), e); continue; } } diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index 058513cd8..cfe55e61d 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -473,7 +473,8 @@ private static String getClientVersion() { String value = attr.getValue("Implementation-Version"); return value; } catch (IOException e) { - logger.warn("Unable to determine LaunchDarkly client library version", e); + logger.warn("Unable to determine LaunchDarkly client library version: {}", e.toString()); + logger.debug(e.toString(), e); return "Unknown"; } } From 1e30caee9b9c4bec98a1565544d3cb55503714f9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 21 Apr 2020 18:51:21 -0700 Subject: [PATCH 296/327] better documentation of client constructor, with singleton advice --- .../com/launchdarkly/client/LDClient.java | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index cfe55e61d..b5e6d587e 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -45,21 +45,52 @@ public final class LDClient implements LDClientInterface { final boolean shouldCloseFeatureStore; /** - * Creates a new client instance that connects to LaunchDarkly with the default configuration. In most - * cases, you should use this constructor. + * Creates a new client instance that connects to LaunchDarkly with the default configuration. + *

    + * If you need to specify any custom SDK options, use {@link LDClient#LDClient(String, LDConfig)} + * instead. + *

    + * Applications should instantiate a single instance for the lifetime of the application. In + * unusual cases where an application needs to evaluate feature flags from different LaunchDarkly + * projects or environments, you may create multiple clients, but they should still be retained + * for the lifetime of the application rather than created on the fly. + *

    + * The client will begin attempting to connect to LaunchDarkly as soon as you call the constructor. + * The constructor will return when it successfully connects, or when the default timeout of 5 seconds + * expires, whichever comes first. If it has not succeeded in connecting when the timeout elapses, + * you will receive the client in an uninitialized state where feature flags will return default + * values; it will still continue trying to connect in the background. You can detect whether + * initialization has succeeded by calling {@link #initialized()}. * * @param sdkKey the SDK key for your LaunchDarkly environment + * @see LDClient#LDClient(String, LDConfig) */ public LDClient(String sdkKey) { this(sdkKey, LDConfig.DEFAULT); } /** - * Creates a new client to connect to LaunchDarkly with a custom configuration. This constructor - * can be used to configure advanced client features, such as customizing the LaunchDarkly base URL. + * Creates a new client to connect to LaunchDarkly with a custom configuration. + *

    + * This constructor can be used to configure advanced SDK features; see {@link LDConfig.Builder}. + *

    + * Applications should instantiate a single instance for the lifetime of the application. In + * unusual cases where an application needs to evaluate feature flags from different LaunchDarkly + * projects or environments, you may create multiple clients, but they should still be retained + * for the lifetime of the application rather than created on the fly. + *

    + * Unless it is configured to be offline with {@link LDConfig.Builder#offline(boolean)} or + * {@link LDConfig.Builder#useLdd(boolean)}, the client will begin attempting to connect to + * LaunchDarkly as soon as you call the constructor. The constructor will return when it successfully + * connects, or when the timeout set by {@link LDConfig.Builder#startWaitMillis(long)} (default: 5 + * seconds) expires, whichever comes first. If it has not succeeded in connecting when the timeout + * elapses, you will receive the client in an uninitialized state where feature flags will return + * default values; it will still continue trying to connect in the background. You can detect + * whether initialization has succeeded by calling {@link #initialized()}. * * @param sdkKey the SDK key for your LaunchDarkly environment * @param config a client configuration object + * @see LDClient#LDClient(String, LDConfig) */ public LDClient(String sdkKey, LDConfig config) { this.config = new LDConfig(checkNotNull(config, "config must not be null")); From 331c7ee15134642a55bfe8e86c78780452ef3d48 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Wed, 22 Apr 2020 11:49:26 -0700 Subject: [PATCH 297/327] copyedit --- src/main/java/com/launchdarkly/client/LDClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDClient.java b/src/main/java/com/launchdarkly/client/LDClient.java index b5e6d587e..f02720ce8 100644 --- a/src/main/java/com/launchdarkly/client/LDClient.java +++ b/src/main/java/com/launchdarkly/client/LDClient.java @@ -53,7 +53,7 @@ public final class LDClient implements LDClientInterface { * Applications should instantiate a single instance for the lifetime of the application. In * unusual cases where an application needs to evaluate feature flags from different LaunchDarkly * projects or environments, you may create multiple clients, but they should still be retained - * for the lifetime of the application rather than created on the fly. + * for the lifetime of the application rather than created per request or per thread. *

    * The client will begin attempting to connect to LaunchDarkly as soon as you call the constructor. * The constructor will return when it successfully connects, or when the default timeout of 5 seconds @@ -77,7 +77,7 @@ public LDClient(String sdkKey) { * Applications should instantiate a single instance for the lifetime of the application. In * unusual cases where an application needs to evaluate feature flags from different LaunchDarkly * projects or environments, you may create multiple clients, but they should still be retained - * for the lifetime of the application rather than created on the fly. + * for the lifetime of the application rather than created per request or per thread. *

    * Unless it is configured to be offline with {@link LDConfig.Builder#offline(boolean)} or * {@link LDConfig.Builder#useLdd(boolean)}, the client will begin attempting to connect to From a46ea5876b5c8bebe8c69a658e2bf36218fe4056 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 14:29:14 -0700 Subject: [PATCH 298/327] relax test app OSGi version constraints to support beta versions --- packaging-test/test-app/build.gradle | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packaging-test/test-app/build.gradle b/packaging-test/test-app/build.gradle index 59d3fd936..76b66db14 100644 --- a/packaging-test/test-app/build.gradle +++ b/packaging-test/test-app/build.gradle @@ -36,6 +36,13 @@ dependencies { jar { bnd( + // This consumer-policy directive completely turns off version checking for the test app's + // OSGi imports, so for instance if the app uses version 2.x of package P, the import will + // just be for p rather than p;version="[2.x,3)". One wouldn't normally do this, but we + // need to be able to run the CI tests for snapshot/beta versions, and bnd does not handle + // those correctly (5.0.0-beta1 will become "[5.0.0,6)" which will not work because the + // beta is semantically *before* 5.0.0). + '-consumer-policy': '', 'Bundle-Activator': 'testapp.TestAppOsgiEntryPoint' ) } From 8e823cccc3fe191ead8687f3c18135254a5110e8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 5 May 2020 17:59:35 -0700 Subject: [PATCH 299/327] further fix to packaging test for beta versions --- build.gradle | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 4e40ec5bd..8edad3a3d 100644 --- a/build.gradle +++ b/build.gradle @@ -249,11 +249,14 @@ def shadeDependencies(jarTask) { } def addOsgiManifest(jarTask, List importConfigs, List exportConfigs) { + // For a prerelease build with "-beta", "-rc", etc., the prerelease qualifier has to be + // removed from the bundle version because OSGi doesn't understand it. + def implementationVersion = version.replaceFirst('-.*$', '') jarTask.manifest { attributes( - "Implementation-Version": version, + "Implementation-Version": implementationVersion, "Bundle-SymbolicName": "com.launchdarkly.client", - "Bundle-Version": version, + "Bundle-Version": implementationVersion, "Bundle-Name": "LaunchDarkly SDK", "Bundle-ManifestVersion": "2", "Bundle-Vendor": "LaunchDarkly" @@ -270,7 +273,7 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleExport(p, a.moduleVersion.id.version) }) From 5d56e8df89caa740dff0c3b2b2f92d3316b79a86 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 15:31:07 -0700 Subject: [PATCH 300/327] set up initial JMH benchmarks --- .circleci/config.yml | 17 +++ .gitignore | 1 + benchmarks/Makefile | 32 +++++ benchmarks/build.gradle | 62 +++++++++ benchmarks/settings.gradle | 1 + .../client/LDClientEvaluationBenchmarks.java | 130 ++++++++++++++++++ .../com/launchdarkly/client/TestValues.java | 72 ++++++++++ 7 files changed, 315 insertions(+) create mode 100644 benchmarks/Makefile create mode 100644 benchmarks/build.gradle create mode 100644 benchmarks/settings.gradle create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java diff --git a/.circleci/config.yml b/.circleci/config.yml index 554291282..b0fa522c7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,6 +30,9 @@ workflows: - packaging: requires: - build-linux + - benchmarks: + requires: + - build-linux - build-test-windows: name: Java 11 - Windows - OpenJDK @@ -131,3 +134,17 @@ jobs: - run: name: run packaging tests command: cd packaging-test && make all + + benchmarks: + docker: + - image: circleci/openjdk:8 + steps: + - run: java -version + - run: sudo apt-get install make -y -q + - checkout + - attach_workspace: + at: build + - run: cat gradle.properties.example >>gradle.properties + - run: + name: run benchmarks + command: cd benchmarks && make diff --git a/.gitignore b/.gitignore index de40ed7a7..b127c5f09 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ out/ classes/ packaging-test/temp/ +benchmarks/lib/ diff --git a/benchmarks/Makefile b/benchmarks/Makefile new file mode 100644 index 000000000..9a6358f3c --- /dev/null +++ b/benchmarks/Makefile @@ -0,0 +1,32 @@ +.PHONY: benchmark clean + +BASE_DIR:=$(shell pwd) +PROJECT_DIR=$(shell cd .. && pwd) +SDK_VERSION=$(shell grep "version=" $(PROJECT_DIR)/gradle.properties | cut -d '=' -f 2) + +BENCHMARK_ALL_JAR=lib/launchdarkly-java-server-sdk-all.jar +BENCHMARK_TEST_JAR=lib/launchdarkly-java-server-sdk-test.jar +SDK_JARS_DIR=$(PROJECT_DIR)/build/libs +SDK_ALL_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-all.jar +SDK_TEST_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-test.jar + +benchmark: $(BENCHMARK_ALL_JAR) $(BENCHMARK_TEST_JAR) + rm -rf build/tmp + ../gradlew jmh + +clean: + rm -rf build lib + +$(BENCHMARK_ALL_JAR): $(SDK_ALL_JAR) + mkdir -p lib + cp $< $@ + +$(BENCHMARK_TEST_JAR): $(SDK_TEST_JAR) + mkdir -p lib + cp $< $@ + +$(SDK_ALL_JAR): + cd .. && ./gradlew shadowJarAll + +$(SDK_TEST_JAR): + cd .. && ./gradlew testCompile diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle new file mode 100644 index 000000000..3f54a72e4 --- /dev/null +++ b/benchmarks/build.gradle @@ -0,0 +1,62 @@ + +buildscript { + repositories { + jcenter() + mavenCentral() + } +} + +plugins { + id "me.champeau.gradle.jmh" version "0.5.0" +} + +repositories { + mavenCentral() +} + +ext.versions = [ + "jmh": "1.21" +] + +dependencies { + // jmh files("lib/launchdarkly-java-server-sdk-all.jar") + // jmh files("lib/launchdarkly-java-server-sdk-test.jar") + // jmh "com.squareup.okhttp3:mockwebserver:3.12.10" + //jmh "org.hamcrest:hamcrest-all:1.3" // the benchmarks don't use this, but the test jar has references to it + + // the "compile" configuration isn't used when running benchmarks, but it allows us to compile the code in an IDE + compile files("lib/launchdarkly-java-server-sdk-all.jar") + compile files("lib/launchdarkly-java-server-sdk-test.jar") + compile "com.squareup.okhttp3:mockwebserver:3.12.10" + // compile "org.hamcrest:hamcrest-all:1.3" + compile "org.openjdk.jmh:jmh-core:1.21" + compile "org.openjdk.jmh:jmh-generator-annprocess:${versions.jmh}" +} + +jmh { + iterations = 10 // Number of measurement iterations to do. + benchmarkMode = ['thrpt'] + // batchSize = 1 // Batch size: number of benchmark method calls per operation. (some benchmark modes can ignore this setting) + fork = 1 // How many times to forks a single benchmark. Use 0 to disable forking altogether + // failOnError = false // Should JMH fail immediately if any benchmark had experienced the unrecoverable error? + // forceGC = false // Should JMH force GC between iterations? + // humanOutputFile = project.file("${project.buildDir}/reports/jmh/human.txt") // human-readable output file + // resultsFile = project.file("${project.buildDir}/reports/jmh/results.txt") // results file + operationsPerInvocation = 3 // Operations per invocation. + // benchmarkParameters = [:] // Benchmark parameters. + profilers = [ 'gc' ] // Use profilers to collect additional data. Supported profilers: [cl, comp, gc, stack, perf, perfnorm, perfasm, xperf, xperfasm, hs_cl, hs_comp, hs_gc, hs_rt, hs_thr] + timeOnIteration = '1s' // Time to spend at each measurement iteration. + // resultFormat = 'CSV' // Result format type (one of CSV, JSON, NONE, SCSV, TEXT) + // synchronizeIterations = false // Synchronize iterations? + // threads = 4 // Number of worker threads to run with. + // timeout = '1s' // Timeout for benchmark iteration. + // timeUnit = 'ms' // Output time unit. Available time units are: [m, s, ms, us, ns]. + // verbosity = 'EXTRA' // Verbosity mode. Available modes are: [SILENT, NORMAL, EXTRA] + warmup = '1s' // Time to spend at each warmup iteration. + warmupBatchSize = 2 // Warmup batch size: number of benchmark method calls per operation. + warmupIterations = 1 // Number of warmup iterations to do. + // warmupForks = 0 // How many warmup forks to make for a single benchmark. 0 to disable warmup forks. + // warmupMode = 'INDI' // Warmup mode for warming up selected benchmarks. Warmup modes are: [INDI, BULK, BULK_INDI]. + + jmhVersion = versions.jmh +} diff --git a/benchmarks/settings.gradle b/benchmarks/settings.gradle new file mode 100644 index 000000000..81d1c11c8 --- /dev/null +++ b/benchmarks/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'launchdarkly-java-server-sdk-benchmarks' diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java new file mode 100644 index 000000000..e684a1fe7 --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java @@ -0,0 +1,130 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.value.LDValue; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; + +import java.util.Random; + +import static com.launchdarkly.client.TestValues.BOOLEAN_FLAG_KEY; +import static com.launchdarkly.client.TestValues.FLAG_WITH_PREREQ_KEY; +import static com.launchdarkly.client.TestValues.FLAG_WITH_TARGET_LIST_KEY; +import static com.launchdarkly.client.TestValues.INT_FLAG_KEY; +import static com.launchdarkly.client.TestValues.JSON_FLAG_KEY; +import static com.launchdarkly.client.TestValues.NOT_TARGETED_USER_KEY; +import static com.launchdarkly.client.TestValues.SDK_KEY; +import static com.launchdarkly.client.TestValues.STRING_FLAG_KEY; +import static com.launchdarkly.client.TestValues.TARGETED_USER_KEYS; +import static com.launchdarkly.client.TestValues.UNKNOWN_FLAG_KEY; +import static com.launchdarkly.client.TestValues.makeTestFlags; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; + +/** + * These benchmarks cover just the evaluation logic itself (and, by necessity, the overhead of getting the + * flag to be evaluated out of the in-memory store). + */ +public class LDClientEvaluationBenchmarks { + @State(Scope.Thread) + public static class BenchmarkInputs { + // Initialization of the things in BenchmarkInputs do not count as part of a benchmark. + final LDClientInterface client; + final LDUser basicUser; + final Random random; + + public BenchmarkInputs() { + FeatureStore featureStore = TestUtil.initedFeatureStore(); + for (FeatureFlag flag: makeTestFlags()) { + featureStore.upsert(FEATURES, flag); + } + + LDConfig config = new LDConfig.Builder() + .dataStore(TestUtil.specificFeatureStore(featureStore)) + .events(Components.noEvents()) + .dataSource(Components.externalUpdatesOnly()) + .build(); + client = new LDClient(SDK_KEY, config); + + basicUser = new LDUser("userkey"); + + random = new Random(); + } + } + + @Benchmark + public void boolVariationForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.boolVariation(BOOLEAN_FLAG_KEY, inputs.basicUser, false); + } + + @Benchmark + public void boolVariationDetailForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.boolVariationDetail(BOOLEAN_FLAG_KEY, inputs.basicUser, false); + } + + @Benchmark + public void boolVariationForUnknownFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.boolVariation(UNKNOWN_FLAG_KEY, inputs.basicUser, false); + } + + @Benchmark + public void intVariationForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.intVariation(INT_FLAG_KEY, inputs.basicUser, 0); + } + + @Benchmark + public void intVariationDetailForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.intVariationDetail(INT_FLAG_KEY, inputs.basicUser, 0); + } + + @Benchmark + public void intVariationForUnknownFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.intVariation(UNKNOWN_FLAG_KEY, inputs.basicUser, 0); + } + + @Benchmark + public void stringVariationForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.stringVariation(STRING_FLAG_KEY, inputs.basicUser, ""); + } + + @Benchmark + public void stringVariationDetailForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.stringVariationDetail(STRING_FLAG_KEY, inputs.basicUser, ""); + } + + @Benchmark + public void stringVariationForUnknownFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.stringVariation(UNKNOWN_FLAG_KEY, inputs.basicUser, ""); + } + + @Benchmark + public void jsonVariationForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.jsonValueVariation(JSON_FLAG_KEY, inputs.basicUser, LDValue.ofNull()); + } + + @Benchmark + public void jsonVariationDetailForSimpleFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.jsonValueVariationDetail(JSON_FLAG_KEY, inputs.basicUser, LDValue.ofNull()); + } + + @Benchmark + public void jsonVariationForUnknownFlag(BenchmarkInputs inputs) throws Exception { + inputs.client.jsonValueVariation(UNKNOWN_FLAG_KEY, inputs.basicUser, LDValue.ofNull()); + } + + @Benchmark + public void userFoundInTargetList(BenchmarkInputs inputs) throws Exception { + String userKey = TARGETED_USER_KEYS.get(inputs.random.nextInt(TARGETED_USER_KEYS.size())); + inputs.client.boolVariation(FLAG_WITH_TARGET_LIST_KEY, new LDUser(userKey), false); + } + + @Benchmark + public void userNotFoundInTargetList(BenchmarkInputs inputs) throws Exception { + inputs.client.boolVariation(FLAG_WITH_TARGET_LIST_KEY, new LDUser(NOT_TARGETED_USER_KEY), false); + } + + @Benchmark + public void flagWithPrerequisite(BenchmarkInputs inputs) throws Exception { + inputs.client.boolVariation(FLAG_WITH_PREREQ_KEY, inputs.basicUser, false); + } +} diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java b/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java new file mode 100644 index 000000000..0c2e12098 --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java @@ -0,0 +1,72 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.value.LDValue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; + +import static com.launchdarkly.client.TestUtil.fallthroughVariation; +import static com.launchdarkly.client.TestUtil.flagWithValue; + +public abstract class TestValues { + private TestValues() {} + + public static final String SDK_KEY = "sdk-key"; + + public static final String BOOLEAN_FLAG_KEY = "flag-bool"; + public static final String INT_FLAG_KEY = "flag-int"; + public static final String STRING_FLAG_KEY = "flag-string"; + public static final String JSON_FLAG_KEY = "flag-json"; + public static final String FLAG_WITH_TARGET_LIST_KEY = "flag-with-targets"; + public static final String FLAG_WITH_PREREQ_KEY = "flag-with-prereq"; + public static final String UNKNOWN_FLAG_KEY = "no-such-flag"; + + public static final List TARGETED_USER_KEYS; + static { + TARGETED_USER_KEYS = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + TARGETED_USER_KEYS.add("user-" + i); + } + } + public static final String NOT_TARGETED_USER_KEY = "no-match"; + + public static final String EMPTY_JSON_DATA = "{\"flags\":{},\"segments\":{}}"; + + public static List makeTestFlags() { + List flags = new ArrayList<>(); + + flags.add(flagWithValue(BOOLEAN_FLAG_KEY, LDValue.of(true))); + flags.add(flagWithValue(INT_FLAG_KEY, LDValue.of(1))); + flags.add(flagWithValue(STRING_FLAG_KEY, LDValue.of("x"))); + flags.add(flagWithValue(JSON_FLAG_KEY, LDValue.buildArray().build())); + + FeatureFlag targetsFlag = new FeatureFlagBuilder(FLAG_WITH_TARGET_LIST_KEY) + .on(true) + .targets(Arrays.asList(new Target(new HashSet(TARGETED_USER_KEYS), 1))) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + flags.add(targetsFlag); + + FeatureFlag prereqFlag = new FeatureFlagBuilder("prereq-flag") + .on(true) + .fallthrough(fallthroughVariation(1)) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + flags.add(prereqFlag); + + FeatureFlag flagWithPrereq = new FeatureFlagBuilder(FLAG_WITH_PREREQ_KEY) + .on(true) + .prerequisites(Arrays.asList(new Prerequisite("prereq-flag", 1))) + .fallthrough(fallthroughVariation(1)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .build(); + flags.add(flagWithPrereq); + + return flags; + } +} From a45df89edc3c04646ec35fded01ba8992a51665c Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 15:48:38 -0700 Subject: [PATCH 301/327] CI fix --- benchmarks/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/Makefile b/benchmarks/Makefile index 9a6358f3c..6597fb21d 100644 --- a/benchmarks/Makefile +++ b/benchmarks/Makefile @@ -29,4 +29,4 @@ $(SDK_ALL_JAR): cd .. && ./gradlew shadowJarAll $(SDK_TEST_JAR): - cd .. && ./gradlew testCompile + cd .. && ./gradlew testJar From a13193f1392558af87b59bb85ee0bfa3b0d103d6 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 15:56:31 -0700 Subject: [PATCH 302/327] save report file --- .circleci/config.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index b0fa522c7..c24a5da9c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -148,3 +148,5 @@ jobs: - run: name: run benchmarks command: cd benchmarks && make + - store_artifacts: + path: benchmarks/build/reports/jmh From fecff19c8604b9459b29e23a46235dd9cbd3545b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 18:25:54 -0700 Subject: [PATCH 303/327] decouple event HTTP logic from event processing --- .../com/launchdarkly/client/Components.java | 19 +- .../client/DefaultEventProcessor.java | 196 ++-- .../client/DefaultEventSender.java | 152 ++++ .../com/launchdarkly/client/EventFactory.java | 1 + .../client/EventsConfiguration.java | 20 +- .../integrations/EventProcessorBuilder.java | 18 + .../client/interfaces/EventSender.java | 96 ++ .../client/interfaces/EventSenderFactory.java | 20 + .../client/DefaultEventProcessorTest.java | 850 +++++++----------- .../client/DefaultEventSenderTest.java | 318 +++++++ .../com/launchdarkly/client/TestUtil.java | 18 +- 11 files changed, 1054 insertions(+), 654 deletions(-) create mode 100644 src/main/java/com/launchdarkly/client/DefaultEventSender.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/EventSender.java create mode 100644 src/main/java/com/launchdarkly/client/interfaces/EventSenderFactory.java create mode 100644 src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java diff --git a/src/main/java/com/launchdarkly/client/Components.java b/src/main/java/com/launchdarkly/client/Components.java index f446eb8e6..7ea7af704 100644 --- a/src/main/java/com/launchdarkly/client/Components.java +++ b/src/main/java/com/launchdarkly/client/Components.java @@ -7,6 +7,7 @@ import com.launchdarkly.client.integrations.PollingDataSourceBuilder; import com.launchdarkly.client.integrations.StreamingDataSourceBuilder; import com.launchdarkly.client.interfaces.DiagnosticDescription; +import com.launchdarkly.client.interfaces.EventSender; import com.launchdarkly.client.interfaces.HttpAuthentication; import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.interfaces.PersistentDataStoreFactory; @@ -393,12 +394,18 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, if (config.offline || !config.deprecatedSendEvents) { return new NullEventProcessor(); } - return new DefaultEventProcessor(sdkKey, + URI eventsBaseUri = config.deprecatedEventsURI == null ? LDConfig.DEFAULT_EVENTS_URI : config.deprecatedEventsURI; + EventSender eventSender = new DefaultEventSender.Factory().createEventSender( + sdkKey, + config.httpConfig + ); + return new DefaultEventProcessor( config, new EventsConfiguration( config.deprecatedAllAttributesPrivate, config.deprecatedCapacity, - config.deprecatedEventsURI == null ? LDConfig.DEFAULT_EVENTS_URI : config.deprecatedEventsURI, + eventSender, + eventsBaseUri, config.deprecatedFlushInterval, config.deprecatedInlineUsersInEvents, config.deprecatedPrivateAttrNames, @@ -407,7 +414,6 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, config.deprecatedUserKeysFlushInterval, EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS ), - config.httpConfig, diagnosticAccumulator ); } @@ -667,11 +673,15 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, Diagn if (config.offline) { return new NullEventProcessor(); } - return new DefaultEventProcessor(sdkKey, + EventSender eventSender = + (eventSenderFactory == null ? new DefaultEventSender.Factory() : eventSenderFactory) + .createEventSender(sdkKey, config.httpConfig); + return new DefaultEventProcessor( config, new EventsConfiguration( allAttributesPrivate, capacity, + eventSender, baseURI == null ? LDConfig.DEFAULT_EVENTS_URI : baseURI, flushIntervalSeconds, inlineUsersInEvents, @@ -681,7 +691,6 @@ public EventProcessor createEventProcessor(String sdkKey, LDConfig config, Diagn userKeysFlushIntervalSeconds, diagnosticRecordingIntervalSeconds ), - config.httpConfig, diagnosticAccumulator ); } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java index a82890e78..436a1872f 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventProcessor.java @@ -3,20 +3,17 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.util.concurrent.ThreadFactoryBuilder; import com.launchdarkly.client.EventSummarizer.EventSummary; -import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.client.interfaces.EventSender; +import com.launchdarkly.client.interfaces.EventSender.EventDataKind; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.StringWriter; -import java.text.ParseException; -import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Date; import java.util.List; import java.util.Random; -import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; @@ -29,24 +26,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; -import static com.launchdarkly.client.Util.configureHttpClientBuilder; -import static com.launchdarkly.client.Util.getHeadersBuilderFor; -import static com.launchdarkly.client.Util.httpErrorMessage; -import static com.launchdarkly.client.Util.isHttpErrorRecoverable; -import static com.launchdarkly.client.Util.shutdownHttpClient; - -import okhttp3.Headers; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; - final class DefaultEventProcessor implements EventProcessor { private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); - private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; - private static final String EVENT_SCHEMA_VERSION = "3"; - private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; @VisibleForTesting final EventDispatcher dispatcher; private final BlockingQueue inbox; @@ -54,8 +35,11 @@ final class DefaultEventProcessor implements EventProcessor { private final AtomicBoolean closed = new AtomicBoolean(false); private volatile boolean inputCapacityExceeded = false; - DefaultEventProcessor(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, - DiagnosticAccumulator diagnosticAccumulator) { + DefaultEventProcessor( + LDConfig config, + EventsConfiguration eventsConfig, + DiagnosticAccumulator diagnosticAccumulator + ) { inbox = new ArrayBlockingQueue<>(eventsConfig.capacity); ThreadFactory threadFactory = new ThreadFactoryBuilder() @@ -65,7 +49,14 @@ final class DefaultEventProcessor implements EventProcessor { .build(); scheduler = Executors.newSingleThreadScheduledExecutor(threadFactory); - dispatcher = new EventDispatcher(sdkKey, config, eventsConfig, httpConfig, inbox, threadFactory, closed, diagnosticAccumulator); + dispatcher = new EventDispatcher( + config, + eventsConfig, + inbox, + threadFactory, + closed, + diagnosticAccumulator + ); Runnable flusher = new Runnable() { public void run() { @@ -207,7 +198,6 @@ static final class EventDispatcher { private static final int MESSAGE_BATCH_SIZE = 50; @VisibleForTesting final EventsConfiguration eventsConfig; - private final OkHttpClient httpClient; private final List flushWorkers; private final AtomicInteger busyFlushWorkersCount; private final Random random = new Random(); @@ -219,18 +209,17 @@ static final class EventDispatcher { private long deduplicatedUsers = 0; - private EventDispatcher(String sdkKey, LDConfig config, EventsConfiguration eventsConfig, HttpConfiguration httpConfig, - final BlockingQueue inbox, - ThreadFactory threadFactory, - final AtomicBoolean closed, - DiagnosticAccumulator diagnosticAccumulator) { + private EventDispatcher( + LDConfig config, + EventsConfiguration eventsConfig, + final BlockingQueue inbox, + ThreadFactory threadFactory, + final AtomicBoolean closed, + DiagnosticAccumulator diagnosticAccumulator + ) { this.eventsConfig = eventsConfig; this.diagnosticAccumulator = diagnosticAccumulator; this.busyFlushWorkersCount = new AtomicInteger(0); - - OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); - configureHttpClientBuilder(httpConfig, httpBuilder); - httpClient = httpBuilder.build(); // This queue only holds one element; it represents a flush task that has not yet been // picked up by any worker, so if we try to push another one and are refused, it means @@ -268,19 +257,24 @@ public void uncaughtException(Thread t, Throwable e) { flushWorkers = new ArrayList<>(); EventResponseListener listener = new EventResponseListener() { - public void handleResponse(Response response, Date responseDate) { - EventDispatcher.this.handleResponse(response, responseDate); + public void handleResponse(EventSender.Result result) { + EventDispatcher.this.handleResponse(result); } }; for (int i = 0; i < MAX_FLUSH_THREADS; i++) { - SendEventsTask task = new SendEventsTask(sdkKey, eventsConfig, httpClient, httpConfig, listener, payloadQueue, - busyFlushWorkersCount, threadFactory); + SendEventsTask task = new SendEventsTask( + eventsConfig, + listener, + payloadQueue, + busyFlushWorkersCount, + threadFactory + ); flushWorkers.add(task); } if (diagnosticAccumulator != null) { // Set up diagnostics - this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(sdkKey, eventsConfig, httpClient, httpConfig); + this.sendDiagnosticTaskFactory = new SendDiagnosticTaskFactory(eventsConfig); diagnosticExecutor = Executors.newSingleThreadExecutor(threadFactory); DiagnosticEvent.Init diagnosticInitEvent = new DiagnosticEvent.Init(diagnosticAccumulator.dataSinceDate, diagnosticAccumulator.diagnosticId, config); diagnosticExecutor.submit(sendDiagnosticTaskFactory.createSendDiagnosticTask(diagnosticInitEvent)); @@ -353,7 +347,12 @@ private void doShutdown() { if (diagnosticExecutor != null) { diagnosticExecutor.shutdown(); } - shutdownHttpClient(httpClient); + try { + eventsConfig.eventSender.close(); + } catch (IOException e) { + logger.error("Unexpected error when closing event sender: {}", e.toString()); + logger.debug(e.toString(), e); + } } private void waitUntilAllFlushWorkersInactive() { @@ -471,68 +470,12 @@ private void triggerFlush(EventBuffer outbox, BlockingQueue payloa } } - private void handleResponse(Response response, Date responseDate) { - if (responseDate != null) { - lastKnownPastTime.set(responseDate.getTime()); + private void handleResponse(EventSender.Result result) { + if (result.getTimeFromServer() != null) { + lastKnownPastTime.set(result.getTimeFromServer().getTime()); } - if (!isHttpErrorRecoverable(response.code())) { + if (result.isMustShutDown()) { disabled.set(true); - logger.error(httpErrorMessage(response.code(), "posting events", "some events were dropped")); - // It's "some events were dropped" because we're not going to retry *this* request any more times - - // we only get to this point if we have used up our retry attempts. So the last batch of events was - // lost, even though we will still try to post *other* events in the future. - } - } - } - - private static void postJson(OkHttpClient httpClient, Headers headers, String json, String uriStr, String descriptor, - EventResponseListener responseListener, SimpleDateFormat dateFormat) { - logger.debug("Posting {} to {} with payload: {}", descriptor, uriStr, json); - - for (int attempt = 0; attempt < 2; attempt++) { - if (attempt > 0) { - logger.warn("Will retry posting {} after 1 second", descriptor); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - } - } - - Request request = new Request.Builder() - .url(uriStr) - .post(RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json)) - .headers(headers) - .build(); - - long startTime = System.currentTimeMillis(); - try (Response response = httpClient.newCall(request).execute()) { - long endTime = System.currentTimeMillis(); - logger.debug("{} delivery took {} ms, response status {}", descriptor, endTime - startTime, response.code()); - if (!response.isSuccessful()) { - logger.warn("Unexpected response status when posting {}: {}", descriptor, response.code()); - if (isHttpErrorRecoverable(response.code())) { - continue; - } - } - if (responseListener != null) { - Date respDate = null; - if (dateFormat != null) { - String dateStr = response.header("Date"); - if (dateStr != null) { - try { - respDate = dateFormat.parse(dateStr); - } catch (ParseException e) { - logger.warn("Received invalid Date header from events service"); - } - } - } - responseListener.handleResponse(response, respDate); - } - break; - } catch (IOException e) { - logger.warn("Unhandled exception in LaunchDarkly client when posting events to URL: {} ({})", request.url(), e.toString()); - logger.debug(e.toString(), e); - continue; } } } @@ -598,36 +541,31 @@ private static final class FlushPayload { } private static interface EventResponseListener { - void handleResponse(Response response, Date responseDate); + void handleResponse(EventSender.Result result); } private static final class SendEventsTask implements Runnable { - private final OkHttpClient httpClient; + private final EventsConfiguration eventsConfig; private final EventResponseListener responseListener; private final BlockingQueue payloadQueue; private final AtomicInteger activeFlushWorkersCount; private final AtomicBoolean stopping; private final EventOutputFormatter formatter; private final Thread thread; - private final Headers headers; - private final String uriStr; - private final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); // need one instance per task because the date parser isn't thread-safe - - SendEventsTask(String sdkKey, EventsConfiguration eventsConfig, OkHttpClient httpClient, HttpConfiguration httpConfig, - EventResponseListener responseListener, BlockingQueue payloadQueue, - AtomicInteger activeFlushWorkersCount, ThreadFactory threadFactory) { - this.httpClient = httpClient; + SendEventsTask( + EventsConfiguration eventsConfig, + EventResponseListener responseListener, + BlockingQueue payloadQueue, + AtomicInteger activeFlushWorkersCount, + ThreadFactory threadFactory + ) { + this.eventsConfig = eventsConfig; this.formatter = new EventOutputFormatter(eventsConfig); this.responseListener = responseListener; this.payloadQueue = payloadQueue; this.activeFlushWorkersCount = activeFlushWorkersCount; this.stopping = new AtomicBoolean(false); - this.uriStr = eventsConfig.eventsUri.toString() + "/bulk"; - this.headers = getHeadersBuilderFor(sdkKey, httpConfig) - .add("Content-Type", "application/json") - .add(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION) - .build(); thread = threadFactory.newThread(this); thread.setDaemon(true); thread.start(); @@ -645,7 +583,13 @@ public void run() { StringWriter stringWriter = new StringWriter(); int outputEventCount = formatter.writeOutputEvents(payload.events, payload.summary, stringWriter); if (outputEventCount > 0) { - postEvents(stringWriter.toString(), outputEventCount); + EventSender.Result result = eventsConfig.eventSender.sendEventData( + EventDataKind.ANALYTICS, + stringWriter.toString(), + outputEventCount, + eventsConfig.eventsUri + ); + responseListener.handleResponse(result); } } catch (Exception e) { logger.error("Unexpected error in event processor: {}", e.toString()); @@ -662,25 +606,13 @@ void stop() { stopping.set(true); thread.interrupt(); } - - private void postEvents(String json, int outputEventCount) { - String eventPayloadId = UUID.randomUUID().toString(); - Headers newHeaders = this.headers.newBuilder().add(EVENT_PAYLOAD_ID_HEADER, eventPayloadId).build(); - postJson(httpClient, newHeaders, json, uriStr, String.format("%d event(s)", outputEventCount), responseListener, httpDateFormat); - } } private static final class SendDiagnosticTaskFactory { - private final OkHttpClient httpClient; - private final String uriStr; - private final Headers headers; + private final EventsConfiguration eventsConfig; - SendDiagnosticTaskFactory(String sdkKey, EventsConfiguration eventsConfig, OkHttpClient httpClient, HttpConfiguration httpConfig) { - this.httpClient = httpClient; - this.uriStr = eventsConfig.eventsUri.toString() + "/diagnostic"; - this.headers = getHeadersBuilderFor(sdkKey, httpConfig) - .add("Content-Type", "application/json") - .build(); + SendDiagnosticTaskFactory(EventsConfiguration eventsConfig) { + this.eventsConfig = eventsConfig; } Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { @@ -688,7 +620,7 @@ Runnable createSendDiagnosticTask(final DiagnosticEvent diagnosticEvent) { @Override public void run() { String json = JsonHelpers.serialize(diagnosticEvent); - postJson(httpClient, headers, json, uriStr, "diagnostic event", null, null); + eventsConfig.eventSender.sendEventData(EventDataKind.DIAGNOSTICS, json, 1, eventsConfig.eventsUri); } }; } diff --git a/src/main/java/com/launchdarkly/client/DefaultEventSender.java b/src/main/java/com/launchdarkly/client/DefaultEventSender.java new file mode 100644 index 000000000..844fbd950 --- /dev/null +++ b/src/main/java/com/launchdarkly/client/DefaultEventSender.java @@ -0,0 +1,152 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.interfaces.EventSender; +import com.launchdarkly.client.interfaces.EventSenderFactory; +import com.launchdarkly.client.interfaces.HttpConfiguration; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.UUID; + +import static com.launchdarkly.client.Util.configureHttpClientBuilder; +import static com.launchdarkly.client.Util.getHeadersBuilderFor; +import static com.launchdarkly.client.Util.httpErrorMessage; +import static com.launchdarkly.client.Util.isHttpErrorRecoverable; +import static com.launchdarkly.client.Util.shutdownHttpClient; + +import okhttp3.Headers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; + +final class DefaultEventSender implements EventSender { + private static final Logger logger = LoggerFactory.getLogger(DefaultEventProcessor.class); + + private static final String EVENT_SCHEMA_HEADER = "X-LaunchDarkly-Event-Schema"; + private static final String EVENT_SCHEMA_VERSION = "3"; + private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; + private static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + + private final OkHttpClient httpClient; + private final Headers baseHeaders; + + DefaultEventSender( + String sdkKey, + HttpConfiguration httpConfiguration + ) { + OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); + configureHttpClientBuilder(httpConfiguration, httpBuilder); + this.httpClient = httpBuilder.build(); + + this.baseHeaders = getHeadersBuilderFor(sdkKey, httpConfiguration) + .add("Content-Type", "application/json") + .build(); + + } + + @Override + public void close() throws IOException { + shutdownHttpClient(httpClient); + } + + @Override + public Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { + Headers.Builder headersBuilder = baseHeaders.newBuilder(); + URI uri; + String description; + + switch (kind) { + case ANALYTICS: + uri = eventsBaseUri.resolve("bulk"); + String eventPayloadId = UUID.randomUUID().toString(); + headersBuilder.add(EVENT_PAYLOAD_ID_HEADER, eventPayloadId); + headersBuilder.add(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION); + description = String.format("%d event(s)", eventCount); + break; + case DIAGNOSTICS: + uri = eventsBaseUri.resolve("diagnostic"); + description = "diagnostic event"; + break; + default: + throw new IllegalArgumentException("kind"); + } + + Headers headers = headersBuilder.build(); + RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), data); + boolean mustShutDown = false; + + logger.debug("Posting {} to {} with payload: {}", description, uri, data); + + for (int attempt = 0; attempt < 2; attempt++) { + if (attempt > 0) { + logger.warn("Will retry posting {} after 1 second", description); + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + } + } + + Request request = new Request.Builder() + .url(uri.toASCIIString()) + .post(body) + .headers(headers) + .build(); + + long startTime = System.currentTimeMillis(); + String nextActionMessage = attempt == 0 ? "will retry" : "some events were dropped"; + + try (Response response = httpClient.newCall(request).execute()) { + long endTime = System.currentTimeMillis(); + logger.debug("{} delivery took {} ms, response status {}", description, endTime - startTime, response.code()); + + if (response.isSuccessful()) { + return new Result(true, false, parseResponseDate(response)); + } + + String logMessage = httpErrorMessage(response.code(), "posting " + description, nextActionMessage); + if (isHttpErrorRecoverable(response.code())) { + logger.error(logMessage); + } else { + logger.warn(logMessage); + mustShutDown = true; + break; + } + } catch (IOException e) { + String message = "Unhandled exception when posting events - " + nextActionMessage + " (" + e.toString() + ")"; + logger.warn(message); + } + } + + return new Result(false, mustShutDown, null); + } + + private static final Date parseResponseDate(Response response) { + String dateStr = response.header("Date"); + if (dateStr != null) { + try { + // DateFormat is not thread-safe, so must synchronize + synchronized (HTTP_DATE_FORMAT) { + return HTTP_DATE_FORMAT.parse(dateStr); + } + } catch (ParseException e) { + logger.warn("Received invalid Date header from events service"); + } + } + return null; + } + + static final class Factory implements EventSenderFactory { + @Override + public EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration) { + return new DefaultEventSender(sdkKey, httpConfiguration); + } + } +} diff --git a/src/main/java/com/launchdarkly/client/EventFactory.java b/src/main/java/com/launchdarkly/client/EventFactory.java index 4afc7240f..09369eb1f 100644 --- a/src/main/java/com/launchdarkly/client/EventFactory.java +++ b/src/main/java/com/launchdarkly/client/EventFactory.java @@ -67,6 +67,7 @@ public Event.Identify newIdentifyEvent(LDUser user) { return new Event.Identify(getTimestamp(), user); } + @SuppressWarnings("deprecation") private boolean isExperiment(FeatureFlag flag, EvaluationReason reason) { if (reason == null) { // doesn't happen in real life, but possible in testing diff --git a/src/main/java/com/launchdarkly/client/EventsConfiguration.java b/src/main/java/com/launchdarkly/client/EventsConfiguration.java index 10f132130..2e91a2807 100644 --- a/src/main/java/com/launchdarkly/client/EventsConfiguration.java +++ b/src/main/java/com/launchdarkly/client/EventsConfiguration.java @@ -1,6 +1,7 @@ package com.launchdarkly.client; import com.google.common.collect.ImmutableSet; +import com.launchdarkly.client.interfaces.EventSender; import java.net.URI; import java.util.Set; @@ -9,6 +10,7 @@ final class EventsConfiguration { final boolean allAttributesPrivate; final int capacity; + final EventSender eventSender; final URI eventsUri; final int flushIntervalSeconds; final boolean inlineUsersInEvents; @@ -18,12 +20,22 @@ final class EventsConfiguration { final int userKeysFlushIntervalSeconds; final int diagnosticRecordingIntervalSeconds; - EventsConfiguration(boolean allAttributesPrivate, int capacity, URI eventsUri, int flushIntervalSeconds, - boolean inlineUsersInEvents, Set privateAttrNames, int samplingInterval, - int userKeysCapacity, int userKeysFlushIntervalSeconds, int diagnosticRecordingIntervalSeconds) { - super(); + EventsConfiguration( + boolean allAttributesPrivate, + int capacity, + EventSender eventSender, + URI eventsUri, + int flushIntervalSeconds, + boolean inlineUsersInEvents, + Set privateAttrNames, + int samplingInterval, + int userKeysCapacity, + int userKeysFlushIntervalSeconds, + int diagnosticRecordingIntervalSeconds + ) { this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity; + this.eventSender = eventSender; this.eventsUri = eventsUri == null ? LDConfig.DEFAULT_EVENTS_URI : eventsUri; this.flushIntervalSeconds = flushIntervalSeconds; this.inlineUsersInEvents = inlineUsersInEvents; diff --git a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java index fb229fc61..2c157a83f 100644 --- a/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java +++ b/src/main/java/com/launchdarkly/client/integrations/EventProcessorBuilder.java @@ -2,6 +2,8 @@ import com.launchdarkly.client.Components; import com.launchdarkly.client.EventProcessorFactory; +import com.launchdarkly.client.interfaces.EventSender; +import com.launchdarkly.client.interfaces.EventSenderFactory; import java.net.URI; import java.util.Arrays; @@ -67,6 +69,7 @@ public abstract class EventProcessorBuilder implements EventProcessorFactory { protected Set privateAttrNames; protected int userKeysCapacity = DEFAULT_USER_KEYS_CAPACITY; protected int userKeysFlushIntervalSeconds = DEFAULT_USER_KEYS_FLUSH_INTERVAL_SECONDS; + protected EventSenderFactory eventSenderFactory = null; /** * Sets whether or not all optional user attributes should be hidden from LaunchDarkly. @@ -138,6 +141,21 @@ public EventProcessorBuilder diagnosticRecordingIntervalSeconds(int diagnosticRe return this; } + /** + * Specifies a custom implementation for event delivery. + *

    + * The standard event delivery implementation sends event data via HTTP/HTTPS to the LaunchDarkly events + * service endpoint (or any other endpoint specified with {@link #baseURI(URI)}. Providing a custom + * implementation may be useful in tests, or if the event data needs to be stored and forwarded. + * + * @param eventSenderFactory a factory for an {@link EventSender} implementation + * @return the builder + */ + public EventProcessorBuilder eventSender(EventSenderFactory eventSenderFactory) { + this.eventSenderFactory = eventSenderFactory; + return this; + } + /** * Sets the interval between flushes of the event buffer. *

    diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventSender.java b/src/main/java/com/launchdarkly/client/interfaces/EventSender.java new file mode 100644 index 000000000..a5da8fcaa --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/EventSender.java @@ -0,0 +1,96 @@ +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.integrations.EventProcessorBuilder; + +import java.io.Closeable; +import java.net.URI; +import java.util.Date; + +/** + * Interface for a component that can deliver preformatted event data. + * + * @see EventProcessorBuilder#eventSender(EventSenderFactory) + * @since 4.14.0 + */ +public interface EventSender extends Closeable { + /** + * Attempt to deliver an event data payload. + *

    + * This method will be called synchronously from an event delivery worker thread. + * + * @param kind specifies which type of event data is being sent + * @param data the preformatted JSON data, as a string + * @param eventCount the number of individual events in the data + * @param eventsBaseUri the configured events endpoint base URI + * @return a {@link Result} + */ + Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri); + + /** + * Enumerated values corresponding to different kinds of event data. + */ + public enum EventDataKind { + /** + * Regular analytics events. + */ + ANALYTICS, + + /** + * Diagnostic data generated by the SDK. + */ + DIAGNOSTICS + }; + + /** + * Encapsulates the results of a call to {@link EventSender#sendEventData(EventDataKind, String, int, URI)}. + */ + public static final class Result { + private boolean success; + private boolean mustShutDown; + private Date timeFromServer; + + /** + * Constructs an instance. + * + * @param success true if the events were delivered + * @param mustShutDown true if an unrecoverable error (such as an HTTP 401 error, implying that the + * SDK key is invalid) means the SDK should permanently stop trying to send events + * @param timeFromServer the parsed value of an HTTP Date header received from the remote server, + * if any; this is used to compensate for differences between the application's time and server time + */ + public Result(boolean success, boolean mustShutDown, Date timeFromServer) { + this.success = success; + this.mustShutDown = mustShutDown; + this.timeFromServer = timeFromServer; + } + + /** + * Returns true if the events were delivered. + * + * @return true if the events were delivered + */ + public boolean isSuccess() { + return success; + } + + /** + * Returns true if an unrecoverable error (such as an HTTP 401 error, implying that the + * SDK key is invalid) means the SDK should permanently stop trying to send events + * + * @return true if event delivery should shut down + */ + public boolean isMustShutDown() { + return mustShutDown; + } + + /** + * Returns the parsed value of an HTTP Date header received from the remote server, if any. This + * is used to compensate for differences between the application's time and server time. + * + * @return a date value or null + */ + public Date getTimeFromServer() { + return timeFromServer; + } + } +} diff --git a/src/main/java/com/launchdarkly/client/interfaces/EventSenderFactory.java b/src/main/java/com/launchdarkly/client/interfaces/EventSenderFactory.java new file mode 100644 index 000000000..d6cc4797e --- /dev/null +++ b/src/main/java/com/launchdarkly/client/interfaces/EventSenderFactory.java @@ -0,0 +1,20 @@ +package com.launchdarkly.client.interfaces; + +import com.launchdarkly.client.integrations.EventProcessorBuilder; + +/** + * Interface for a factory that creates some implementation of {@link EventSender}. + * + * @see EventProcessorBuilder#eventSender(EventSenderFactory) + * @since 4.14.0 + */ +public interface EventSenderFactory { + /** + * Called by the SDK to create the implementation object. + * + * @param sdkKey the configured SDK key + * @param httpConfiguration HTTP configuration properties + * @return an {@link EventSender} + */ + EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration); +} diff --git a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java index 3ec9aab0b..1d02a95a8 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventProcessorTest.java @@ -5,21 +5,23 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.launchdarkly.client.integrations.EventProcessorBuilder; +import com.launchdarkly.client.interfaces.EventSender; +import com.launchdarkly.client.interfaces.EventSenderFactory; +import com.launchdarkly.client.interfaces.HttpConfiguration; import com.launchdarkly.client.value.LDValue; import org.hamcrest.Matcher; import org.hamcrest.Matchers; import org.junit.Test; +import java.io.IOException; import java.net.URI; -import java.text.SimpleDateFormat; import java.util.Date; -import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import static com.launchdarkly.client.Components.sendEvents; -import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; -import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; import static com.launchdarkly.client.TestUtil.hasJsonProperty; import static com.launchdarkly.client.TestUtil.isJsonArray; import static com.launchdarkly.client.TestUtil.simpleEvaluation; @@ -27,18 +29,14 @@ import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.Matchers.sameInstance; import static org.hamcrest.Matchers.samePropertyValuesAs; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; - -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; +import static org.junit.Assert.fail; @SuppressWarnings("javadoc") public class DefaultEventProcessorTest { @@ -49,15 +47,14 @@ public class DefaultEventProcessorTest { gson.fromJson("{\"key\":\"userkey\",\"name\":\"Red\"}", JsonElement.class); private static final JsonElement filteredUserJson = gson.fromJson("{\"key\":\"userkey\",\"privateAttrs\":[\"name\"]}", JsonElement.class); - private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); private static final LDConfig baseLDConfig = new LDConfig.Builder().diagnosticOptOut(true).build(); private static final LDConfig diagLDConfig = new LDConfig.Builder().diagnosticOptOut(false).build(); // Note that all of these events depend on the fact that DefaultEventProcessor does a synchronous // flush when it is closed; in this case, it's closed implicitly by the try-with-resources block. - private EventProcessorBuilder baseConfig(MockWebServer server) { - return sendEvents().baseURI(server.url("").uri()); + private EventProcessorBuilder baseConfig(MockEventSender es) { + return sendEvents().eventSender(senderFactory(es)); } private DefaultEventProcessor makeEventProcessor(EventProcessorBuilder ec) { @@ -81,6 +78,7 @@ public void builderHasDefaultConfiguration() throws Exception { assertThat(ec.allAttributesPrivate, is(false)); assertThat(ec.capacity, equalTo(EventProcessorBuilder.DEFAULT_CAPACITY)); assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS)); + assertThat(ec.eventSender, instanceOf(DefaultEventSender.class)); assertThat(ec.eventsUri, equalTo(LDConfig.DEFAULT_EVENTS_URI)); assertThat(ec.flushIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_FLUSH_INTERVAL_SECONDS)); assertThat(ec.inlineUsersInEvents, is(false)); @@ -94,11 +92,13 @@ public void builderHasDefaultConfiguration() throws Exception { @Test public void builderCanSpecifyConfiguration() throws Exception { URI uri = URI.create("http://fake"); + MockEventSender es = new MockEventSender(); EventProcessorFactory epf = Components.sendEvents() .allAttributesPrivate(true) .baseURI(uri) .capacity(3333) .diagnosticRecordingIntervalSeconds(480) + .eventSender(senderFactory(es)) .flushIntervalSeconds(99) .privateAttributeNames("cats", "dogs") .userKeysCapacity(555) @@ -108,6 +108,7 @@ public void builderCanSpecifyConfiguration() throws Exception { assertThat(ec.allAttributesPrivate, is(true)); assertThat(ec.capacity, equalTo(3333)); assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(480)); + assertThat(ec.eventSender, sameInstance((EventSender)es)); assertThat(ec.eventsUri, equalTo(uri)); assertThat(ec.flushIntervalSeconds, equalTo(99)); assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below @@ -147,6 +148,7 @@ public void deprecatedConfigurationIsUsedWhenBuilderIsNotUsed() throws Exception assertThat(ec.capacity, equalTo(3333)); assertThat(ec.diagnosticRecordingIntervalSeconds, equalTo(EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS)); // can't set diagnosticRecordingIntervalSeconds with deprecated API, must use builder + assertThat(ec.eventSender, instanceOf(DefaultEventSender.class)); assertThat(ec.eventsUri, equalTo(uri)); assertThat(ec.flushIntervalSeconds, equalTo(99)); assertThat(ec.inlineUsersInEvents, is(false)); // will test this separately below @@ -190,267 +192,261 @@ public void deprecatedConfigurationHasSameDefaultsAsBuilder() throws Exception { @Test public void identifyEventIsQueued() throws Exception { + MockEventSender es = new MockEventSender(); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIdentifyEvent(e, userJson) - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(e); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(e, userJson) + )); } @Test public void userIsFilteredInIdentifyEvent() throws Exception { + MockEventSender es = new MockEventSender(); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).allAttributesPrivate(true))) { - ep.sendEvent(e); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIdentifyEvent(e, filteredUserJson) - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true))) { + ep.sendEvent(e); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(e, filteredUserJson) + )); } @SuppressWarnings("unchecked") @Test public void individualFeatureEventIsQueuedWithIndexEvent() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, userJson), - isFeatureEvent(fe, flag, false, null), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void userIsFilteredInIndexEvent() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).allAttributesPrivate(true))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, filteredUserJson), - isFeatureEvent(fe, flag, false, null), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).allAttributesPrivate(true))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, filteredUserJson), + isFeatureEvent(fe, flag, false, null), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void featureEventCanContainInlineUser() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).inlineUsersInEvents(true))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isFeatureEvent(fe, flag, false, userJson), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).inlineUsersInEvents(true))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isFeatureEvent(fe, flag, false, userJson), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void userIsFilteredInFeatureEvent() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server) - .inlineUsersInEvents(true).allAttributesPrivate(true))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isFeatureEvent(fe, flag, false, filteredUserJson), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) + .inlineUsersInEvents(true).allAttributesPrivate(true))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isFeatureEvent(fe, flag, false, filteredUserJson), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void featureEventCanContainReason() throws Exception { + MockEventSender es = new MockEventSender(); 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, EvaluationDetail.fromValue(LDValue.of("value"), 1, reason), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, userJson), - isFeatureEvent(fe, flag, false, null, reason), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null, reason), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void indexEventIsStillGeneratedIfInlineUsersIsTrueButFeatureEventIsNotTracked() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(false).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).inlineUsersInEvents(true))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, userJson), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).inlineUsersInEvents(true))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void eventKindIsDebugIfFlagIsTemporarilyInDebugMode() throws Exception { + MockEventSender es = new MockEventSender(); long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, userJson), - isFeatureEvent(fe, flag, true, userJson), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, true, userJson), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void eventCanBeBothTrackedAndDebugged() throws Exception { + MockEventSender es = new MockEventSender(); long futureTime = System.currentTimeMillis() + 1000000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true) .debugEventsUntilDate(futureTime).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, userJson), - isFeatureEvent(fe, flag, false, null), - isFeatureEvent(fe, flag, true, userJson), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isFeatureEvent(fe, flag, false, null), + isFeatureEvent(fe, flag, true, userJson), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void debugModeExpiresBasedOnClientTimeIfClientTimeIsLaterThanServerTime() throws Exception { + MockEventSender es = new MockEventSender(); + // Pick a server time that is somewhat behind the client time long serverTime = System.currentTimeMillis() - 20000; - MockResponse resp1 = addDateHeader(eventsSuccessResponse(), serverTime); - MockResponse resp2 = eventsSuccessResponse(); + es.result = new EventSender.Result(true, false, new Date(serverTime)); long debugUntil = serverTime + 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(resp1, resp2)) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time - ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); - ep.flush(); - ep.waitUntilInactive(); // this ensures that it has received the server response (resp1) - server.takeRequest(); // discard the first request - - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the server time, but in the past compared to the client. - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + // Send and flush an event we don't care about, just so we'll receive "resp1" which sets the last server time + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); + ep.flush(); + ep.waitUntilInactive(); // this ensures that it has received the first response, with the date + + es.receivedParams.clear(); + es.result = new EventSender.Result(true, false, null); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the server time, but in the past compared to the client. + ep.sendEvent(fe); } + + // Should get a summary event only, not a full feature event + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isSummaryEvent(fe.creationDate, fe.creationDate) + )); } @SuppressWarnings("unchecked") @Test public void debugModeExpiresBasedOnServerTimeIfServerTimeIsLaterThanClientTime() throws Exception { + MockEventSender es = new MockEventSender(); + // Pick a server time that is somewhat ahead of the client time long serverTime = System.currentTimeMillis() + 20000; - MockResponse resp1 = addDateHeader(eventsSuccessResponse(), serverTime); - MockResponse resp2 = eventsSuccessResponse(); + es.result = new EventSender.Result(true, false, new Date(serverTime)); long debugUntil = serverTime - 1000; FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).debugEventsUntilDate(debugUntil).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(resp1, resp2)) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - // Send and flush an event we don't care about, just to set the last server time - ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); - ep.flush(); - ep.waitUntilInactive(); // this ensures that it has received the server response (resp1) - server.takeRequest(); - - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the client time, but in the past compared to the server. - ep.sendEvent(fe); - } + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + // Send and flush an event we don't care about, just to set the last server time + ep.sendEvent(EventFactory.DEFAULT.newIdentifyEvent(new LDUser.Builder("otherUser").build())); + ep.flush(); + ep.waitUntilInactive(); // this ensures that it has received the first response, with the date - // Should get a summary event only, not a full feature event - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe, userJson), - isSummaryEvent(fe.creationDate, fe.creationDate) - )); + es.receivedParams.clear(); + es.result = new EventSender.Result(true, false, null); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the client time, but in the past compared to the server. + ep.sendEvent(fe); } + + // Should get a summary event only, not a full feature event + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe, userJson), + isSummaryEvent(fe.creationDate, fe.creationDate) + )); } @SuppressWarnings("unchecked") @Test public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).trackEvents(true).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); @@ -459,47 +455,45 @@ public void twoFeatureEventsForSameUserGenerateOnlyOneIndexEvent() throws Except Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, simpleEvaluation(1, value), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(fe1); - ep.sendEvent(fe2); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe1, userJson), - isFeatureEvent(fe1, flag1, false, null), - isFeatureEvent(fe2, flag2, false, null), - isSummaryEvent(fe1.creationDate, fe2.creationDate) - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe1); + ep.sendEvent(fe2); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe1, userJson), + isFeatureEvent(fe1, flag1, false, null), + isFeatureEvent(fe2, flag2, false, null), + isSummaryEvent(fe1.creationDate, fe2.creationDate) + )); } @SuppressWarnings("unchecked") @Test public void identifyEventMakesIndexEventUnnecessary() throws Exception { + MockEventSender es = new MockEventSender(); Event ie = EventFactory.DEFAULT.newIdentifyEvent(user); FeatureFlag flag = new FeatureFlagBuilder("flagkey").version(11).trackEvents(true).build(); Event.FeatureRequest fe = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, simpleEvaluation(1, LDValue.of("value")), null); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(ie); - ep.sendEvent(fe); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIdentifyEvent(ie, userJson), - isFeatureEvent(fe, flag, false, null), - isSummaryEvent() - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(ie); + ep.sendEvent(fe); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIdentifyEvent(ie, userJson), + isFeatureEvent(fe, flag, false, null), + isSummaryEvent() + )); } @SuppressWarnings("unchecked") @Test public void nonTrackedEventsAreSummarized() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).build(); LDValue value1 = LDValue.of("value1"); @@ -515,167 +509,156 @@ public void nonTrackedEventsAreSummarized() throws Exception { Event fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, simpleEvaluation(2, value2), default2); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(fe1a); - ep.sendEvent(fe1b); - ep.sendEvent(fe1c); - ep.sendEvent(fe2); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(fe1a, userJson), - allOf( - isSummaryEvent(fe1a.creationDate, fe2.creationDate), - hasSummaryFlag(flag1.getKey(), default1, - Matchers.containsInAnyOrder( - isSummaryEventCounter(flag1, 1, value1, 2), - isSummaryEventCounter(flag1, 2, value2, 1) - )), - hasSummaryFlag(flag2.getKey(), default2, - contains(isSummaryEventCounter(flag2, 2, value2, 1))) - ) - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(fe1a); + ep.sendEvent(fe1b); + ep.sendEvent(fe1c); + ep.sendEvent(fe2); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(fe1a, userJson), + allOf( + isSummaryEvent(fe1a.creationDate, fe2.creationDate), + hasSummaryFlag(flag1.getKey(), default1, + Matchers.containsInAnyOrder( + isSummaryEventCounter(flag1, 1, value1, 2), + isSummaryEventCounter(flag1, 2, value2, 1) + )), + hasSummaryFlag(flag2.getKey(), default2, + contains(isSummaryEventCounter(flag2, 2, value2, 1))) + ) + )); } @SuppressWarnings("unchecked") @Test public void customEventIsQueuedWithUser() throws Exception { + MockEventSender es = new MockEventSender(); LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); double metric = 1.5; Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, metric); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(ce); - } - - assertThat(getEventsFromLastRequest(server), contains( - isIndexEvent(ce, userJson), - isCustomEvent(ce, null) - )); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(ce); } + + assertThat(es.getEventsFromLastRequest(), contains( + isIndexEvent(ce, userJson), + isCustomEvent(ce, null) + )); } @Test public void customEventCanContainInlineUser() throws Exception { + MockEventSender es = new MockEventSender(); LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server).inlineUsersInEvents(true))) { - ep.sendEvent(ce); - } - - assertThat(getEventsFromLastRequest(server), contains(isCustomEvent(ce, userJson))); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).inlineUsersInEvents(true))) { + ep.sendEvent(ce); } + + assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(ce, userJson))); } @Test public void userIsFilteredInCustomEvent() throws Exception { + MockEventSender es = new MockEventSender(); LDValue data = LDValue.buildObject().put("thing", LDValue.of("stuff")).build(); Event.Custom ce = EventFactory.DEFAULT.newCustomEvent("eventkey", user, data, null); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server) - .inlineUsersInEvents(true).allAttributesPrivate(true))) { - ep.sendEvent(ce); - } - - assertThat(getEventsFromLastRequest(server), contains(isCustomEvent(ce, filteredUserJson))); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es) + .inlineUsersInEvents(true).allAttributesPrivate(true))) { + ep.sendEvent(ce); } + + assertThat(es.getEventsFromLastRequest(), contains(isCustomEvent(ce, filteredUserJson))); } @Test public void closingEventProcessorForcesSynchronousFlush() throws Exception { + MockEventSender es = new MockEventSender(); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); - } - - assertThat(getEventsFromLastRequest(server), contains(isIdentifyEvent(e, userJson))); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es))) { + ep.sendEvent(e); } + + assertThat(es.getEventsFromLastRequest(), contains(isIdentifyEvent(e, userJson))); } @Test public void nothingIsSentIfThereAreNoEvents() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DefaultEventProcessor ep = makeEventProcessor(baseConfig(server)); - ep.close(); + MockEventSender es = new MockEventSender(); + DefaultEventProcessor ep = makeEventProcessor(baseConfig(es)); + ep.close(); - assertEquals(0, server.getRequestCount()); - } + assertEquals(0, es.receivedParams.size()); } @Test public void diagnosticEventsSentToDiagnosticEndpoint() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), diagnosticAccumulator)) { - RecordedRequest initReq = server.takeRequest(); - ep.postDiagnostic(); - RecordedRequest periodicReq = server.takeRequest(); - - assertThat(initReq.getPath(), equalTo("//diagnostic")); - assertThat(periodicReq.getPath(), equalTo("//diagnostic")); - } + MockEventSender es = new MockEventSender(); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es), diagnosticAccumulator)) { + MockEventSender.Params initReq = es.awaitRequest(); + ep.postDiagnostic(); + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertThat(initReq.kind, equalTo(EventSender.EventDataKind.DIAGNOSTICS)); + assertThat(periodicReq.kind, equalTo(EventSender.EventDataKind.DIAGNOSTICS)); } } @Test public void initialDiagnosticEventHasInitBody() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), diagnosticAccumulator)) { - RecordedRequest req = server.takeRequest(); - - assertNotNull(req); - - DiagnosticEvent.Init initEvent = gson.fromJson(req.getBody().readUtf8(), DiagnosticEvent.Init.class); - - assertNotNull(initEvent); - assertThat(initEvent.kind, equalTo("diagnostic-init")); - assertThat(initEvent.id, samePropertyValuesAs(diagnosticId)); - assertNotNull(initEvent.configuration); - assertNotNull(initEvent.sdk); - assertNotNull(initEvent.platform); - } + MockEventSender es = new MockEventSender(); + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es), diagnosticAccumulator)) { + MockEventSender.Params req = es.awaitRequest(); + + DiagnosticEvent.Init initEvent = gson.fromJson(req.data, DiagnosticEvent.Init.class); + + assertNotNull(initEvent); + assertThat(initEvent.kind, equalTo("diagnostic-init")); + assertThat(initEvent.id, samePropertyValuesAs(diagnosticId)); + assertNotNull(initEvent.configuration); + assertNotNull(initEvent.sdk); + assertNotNull(initEvent.platform); } } @Test public void periodicDiagnosticEventHasStatisticsBody() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse(), eventsSuccessResponse())) { - DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); - long dataSinceDate = diagnosticAccumulator.dataSinceDate; - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), diagnosticAccumulator)) { - // Ignore the initial diagnostic event - server.takeRequest(); - ep.postDiagnostic(); - RecordedRequest periodicReq = server.takeRequest(); - - assertNotNull(periodicReq); - DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.getBody().readUtf8(), DiagnosticEvent.Statistics.class); - - assertNotNull(statsEvent); - assertThat(statsEvent.kind, equalTo("diagnostic")); - assertThat(statsEvent.id, samePropertyValuesAs(diagnosticId)); - assertThat(statsEvent.dataSinceDate, equalTo(dataSinceDate)); - assertThat(statsEvent.creationDate, equalTo(diagnosticAccumulator.dataSinceDate)); - assertThat(statsEvent.deduplicatedUsers, equalTo(0L)); - assertThat(statsEvent.eventsInLastBatch, equalTo(0L)); - assertThat(statsEvent.droppedEvents, equalTo(0L)); - } + MockEventSender es = new MockEventSender(); + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + long dataSinceDate = diagnosticAccumulator.dataSinceDate; + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es), diagnosticAccumulator)) { + // Ignore the initial diagnostic event + es.awaitRequest(); + ep.postDiagnostic(); + MockEventSender.Params periodicReq = es.awaitRequest(); + + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); + + assertNotNull(statsEvent); + assertThat(statsEvent.kind, equalTo("diagnostic")); + assertThat(statsEvent.id, samePropertyValuesAs(diagnosticId)); + assertThat(statsEvent.dataSinceDate, equalTo(dataSinceDate)); + assertThat(statsEvent.creationDate, equalTo(diagnosticAccumulator.dataSinceDate)); + assertThat(statsEvent.deduplicatedUsers, equalTo(0L)); + assertThat(statsEvent.eventsInLastBatch, equalTo(0L)); + assertThat(statsEvent.droppedEvents, equalTo(0L)); } } @Test public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() throws Exception { + MockEventSender es = new MockEventSender(); FeatureFlag flag1 = new FeatureFlagBuilder("flagkey1").version(11).trackEvents(true).build(); FeatureFlag flag2 = new FeatureFlagBuilder("flagkey2").version(22).trackEvents(true).build(); LDValue value = LDValue.of("value"); @@ -684,273 +667,122 @@ public void periodicDiagnosticEventGetsEventsInLastBatchAndDeduplicatedUsers() t Event.FeatureRequest fe2 = EventFactory.DEFAULT.newFeatureRequestEvent(flag2, user, simpleEvaluation(1, value), LDValue.ofNull()); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse(), eventsSuccessResponse())) { - DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), diagnosticAccumulator)) { - // Ignore the initial diagnostic event - server.takeRequest(); - - ep.sendEvent(fe1); - ep.sendEvent(fe2); - ep.flush(); - // Ignore normal events - server.takeRequest(); - - ep.postDiagnostic(); - RecordedRequest periodicReq = server.takeRequest(); - - assertNotNull(periodicReq); - DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.getBody().readUtf8(), DiagnosticEvent.Statistics.class); - - assertNotNull(statsEvent); - assertThat(statsEvent.deduplicatedUsers, equalTo(1L)); - assertThat(statsEvent.eventsInLastBatch, equalTo(3L)); - assertThat(statsEvent.droppedEvents, equalTo(0L)); - } - } - } - - - @Test - public void sdkKeyIsSent() throws Exception { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es), diagnosticAccumulator)) { + // Ignore the initial diagnostic event + es.awaitRequest(); - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); - } - - RecordedRequest req = server.takeRequest(); - assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); - } - } + ep.sendEvent(fe1); + ep.sendEvent(fe2); + ep.flush(); + // Ignore normal events + es.awaitRequest(); - @Test - public void sdkKeyIsSentOnDiagnosticEvents() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), diagnosticAccumulator)) { - RecordedRequest initReq = server.takeRequest(); - ep.postDiagnostic(); - RecordedRequest periodicReq = server.takeRequest(); - - assertThat(initReq.getHeader("Authorization"), equalTo(SDK_KEY)); - assertThat(periodicReq.getHeader("Authorization"), equalTo(SDK_KEY)); - } - } - } + ep.postDiagnostic(); + MockEventSender.Params periodicReq = es.awaitRequest(); - @Test - public void eventSchemaIsSent() throws Exception { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); - } + assertNotNull(periodicReq); + DiagnosticEvent.Statistics statsEvent = gson.fromJson(periodicReq.data, DiagnosticEvent.Statistics.class); - RecordedRequest req = server.takeRequest(); - assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("3")); + assertNotNull(statsEvent); + assertThat(statsEvent.deduplicatedUsers, equalTo(1L)); + assertThat(statsEvent.eventsInLastBatch, equalTo(3L)); + assertThat(statsEvent.droppedEvents, equalTo(0L)); } } @Test - public void eventPayloadIdIsSent() throws Exception { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); - } - - RecordedRequest req = server.takeRequest(); - String payloadHeaderValue = req.getHeader("X-LaunchDarkly-Payload-ID"); - assertThat(payloadHeaderValue, notNullValue(String.class)); - assertThat(UUID.fromString(payloadHeaderValue), notNullValue(UUID.class)); - } + public void eventSenderIsClosedWithEventProcessor() throws Exception { + MockEventSender es = new MockEventSender(); + assertThat(es.closed, is(false)); + DefaultEventProcessor ep = makeEventProcessor(baseConfig(es)); + ep.close(); + assertThat(es.closed, is(true)); } - + @Test - public void eventPayloadIdReusedOnRetry() throws Exception { - MockResponse errorResponse = new MockResponse().setResponseCode(429); + public void customBaseUriIsPassedToEventSenderForAnalyticsEvents() throws Exception { + MockEventSender es = new MockEventSender(); Event e = EventFactory.DEFAULT.newIdentifyEvent(user); + URI uri = URI.create("fake-uri"); - try (MockWebServer server = makeStartedServer(errorResponse, eventsSuccessResponse(), eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); - ep.flush(); - // Necessary to ensure the retry occurs before the second request for test assertion ordering - ep.waitUntilInactive(); - ep.sendEvent(e); - } - - // Failed response request - RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); - String payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); - // Retry request has same payload ID as failed request - req = server.takeRequest(0, TimeUnit.SECONDS); - String retryId = req.getHeader("X-LaunchDarkly-Payload-ID"); - assertThat(retryId, equalTo(payloadId)); - // Second request has different payload ID from first request - req = server.takeRequest(0, TimeUnit.SECONDS); - payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); - assertThat(retryId, not(equalTo(payloadId))); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).baseURI(uri))) { + ep.sendEvent(e); } + + MockEventSender.Params p = es.awaitRequest(); + assertThat(p.eventsBaseUri, equalTo(uri)); } @Test - public void eventSchemaNotSetOnDiagnosticEvents() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(new DiagnosticId(SDK_KEY)); - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), diagnosticAccumulator)) { - RecordedRequest initReq = server.takeRequest(); - ep.postDiagnostic(); - RecordedRequest periodicReq = server.takeRequest(); - - assertNull(initReq.getHeader("X-LaunchDarkly-Event-Schema")); - assertNull(periodicReq.getHeader("X-LaunchDarkly-Event-Schema")); - } - } - } + public void customBaseUriIsPassedToEventSenderForDiagnosticEvents() throws Exception { + MockEventSender es = new MockEventSender(); + URI uri = URI.create("fake-uri"); + DiagnosticId diagnosticId = new DiagnosticId(SDK_KEY); + DiagnosticAccumulator diagnosticAccumulator = new DiagnosticAccumulator(diagnosticId); - @Test - public void wrapperHeaderSentWhenSet() throws Exception { - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - LDConfig config = new LDConfig.Builder() - .diagnosticOptOut(true) - .http(Components.httpConfiguration().wrapper("Scala", "0.1.0")) - .build(); - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server), config)) { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - } - - RecordedRequest req = server.takeRequest(); - assertThat(req.getHeader("X-LaunchDarkly-Wrapper"), equalTo("Scala/0.1.0")); + try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(es).baseURI(uri), diagnosticAccumulator)) { } - } - @Test - public void http400ErrorIsRecoverable() throws Exception { - testRecoverableHttpError(400); + MockEventSender.Params p = es.awaitRequest(); + assertThat(p.eventsBaseUri, equalTo(uri)); } - @Test - public void http401ErrorIsUnrecoverable() throws Exception { - testUnrecoverableHttpError(401); - } - - @Test - public void http403ErrorIsUnrecoverable() throws Exception { - testUnrecoverableHttpError(403); - } - - // Cannot test our retry logic for 408, because OkHttp insists on doing its own retry on 408 so that - // we never actually see that response status. -// @Test -// public void http408ErrorIsRecoverable() throws Exception { -// testRecoverableHttpError(408); -// } - - @Test - public void http429ErrorIsRecoverable() throws Exception { - testRecoverableHttpError(429); - } - - @Test - public void http500ErrorIsRecoverable() throws Exception { - testRecoverableHttpError(500); - } - - @Test - public void flushIsRetriedOnceAfter5xxError() throws Exception { - } - - @Test - public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { - try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { - EventProcessorBuilder ec = sendEvents().baseURI(serverWithCert.uri()); - try (DefaultEventProcessor ep = makeEventProcessor(ec)) { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - - ep.flush(); - ep.waitUntilInactive(); + private static EventSenderFactory senderFactory(final MockEventSender es) { + return new EventSenderFactory() { + @Override + public EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration) { + return es; } - - assertEquals(0, serverWithCert.server.getRequestCount()); - } + }; } - @Test - public void httpClientCanUseCustomTlsConfig() throws Exception { - try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { - EventProcessorBuilder ec = sendEvents().baseURI(serverWithCert.uri()); - LDConfig config = new LDConfig.Builder() - .http(Components.httpConfiguration().sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager)) - // allows us to trust the self-signed cert - .build(); + private static final class MockEventSender implements EventSender { + volatile boolean closed; + volatile Result result = new Result(true, false, null); + final BlockingQueue receivedParams = new LinkedBlockingQueue<>(); + + static final class Params { + final EventDataKind kind; + final String data; + final int eventCount; + final URI eventsBaseUri; - try (DefaultEventProcessor ep = makeEventProcessor(ec, config)) { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - ep.sendEvent(e); - - ep.flush(); - ep.waitUntilInactive(); + Params(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { + this.kind = kind; + this.data = data; + this.eventCount = eventCount; + assertNotNull(eventsBaseUri); + this.eventsBaseUri = eventsBaseUri; } - - assertEquals(1, serverWithCert.server.getRequestCount()); } - } - - private void testUnrecoverableHttpError(int status) throws Exception { - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - - try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); - } + + @Override + public void close() throws IOException { + closed = true; + } - RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, notNullValue(RecordedRequest.class)); // this was the initial request that received the error - - // it does not retry after this type of error, so there are no more requests - assertThat(server.takeRequest(0, TimeUnit.SECONDS), nullValue(RecordedRequest.class)); + @Override + public Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { + receivedParams.add(new Params(kind, data, eventCount, eventsBaseUri)); + return result; } - } - - private void testRecoverableHttpError(int status) throws Exception { - MockResponse errorResponse = new MockResponse().setResponseCode(status); - Event e = EventFactory.DEFAULT.newIdentifyEvent(user); - - // send two errors in a row, because the flush will be retried one time - try (MockWebServer server = makeStartedServer(errorResponse, errorResponse, eventsSuccessResponse())) { - try (DefaultEventProcessor ep = makeEventProcessor(baseConfig(server))) { - ep.sendEvent(e); + + Params awaitRequest() throws Exception { + Params p = receivedParams.poll(5, TimeUnit.SECONDS); + if (p == null) { + fail("did not receive event post within 5 seconds"); } - - RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, notNullValue(RecordedRequest.class)); - req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, notNullValue(RecordedRequest.class)); - req = server.takeRequest(0, TimeUnit.SECONDS); - assertThat(req, nullValue(RecordedRequest.class)); // only 2 requests total + return p; + } + + JsonArray getEventsFromLastRequest() throws Exception { + Params p = awaitRequest(); + JsonArray a = gson.fromJson(p.data, JsonElement.class).getAsJsonArray(); + assertEquals(p.eventCount, a.size()); + return a; } - } - - private MockResponse eventsSuccessResponse() { - return new MockResponse().setResponseCode(202); - } - - private MockResponse addDateHeader(MockResponse response, long timestamp) { - return response.addHeader("Date", httpDateFormat.format(new Date(timestamp))); - } - - private JsonArray getEventsFromLastRequest(MockWebServer server) throws Exception { - RecordedRequest req = server.takeRequest(0, TimeUnit.MILLISECONDS); - assertNotNull(req); - return gson.fromJson(req.getBody().readUtf8(), JsonElement.class).getAsJsonArray(); } private Matcher isIdentifyEvent(Event sourceEvent, JsonElement user) { diff --git a/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java new file mode 100644 index 000000000..61425d466 --- /dev/null +++ b/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java @@ -0,0 +1,318 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.interfaces.EventSender; +import com.launchdarkly.client.interfaces.HttpConfiguration; + +import org.junit.Test; + +import java.net.URI; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; +import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static com.launchdarkly.client.interfaces.EventSender.EventDataKind.ANALYTICS; +import static com.launchdarkly.client.interfaces.EventSender.EventDataKind.DIAGNOSTICS; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +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 okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +@SuppressWarnings("javadoc") +public class DefaultEventSenderTest { + private static final String SDK_KEY = "SDK_KEY"; + private static final String FAKE_DATA = "some data"; + private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + + private static EventSender makeEventSender() { + return makeEventSender(Components.httpConfiguration().createHttpConfiguration()); + } + + private static EventSender makeEventSender(HttpConfiguration httpConfiguration) { + return new DefaultEventSender( + SDK_KEY, + httpConfiguration + ); + } + + private static URI getBaseUri(MockWebServer server) { + return server.url("/").uri(); + } + + @Test + public void analyticsDataIsDelivered() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RecordedRequest req = server.takeRequest(); + assertEquals("/bulk", req.getPath()); + assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); + assertEquals(FAKE_DATA, req.getBody().readUtf8()); + } + } + + @Test + public void diagnosticDataIsDelivered() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RecordedRequest req = server.takeRequest(); + assertEquals("/diagnostic", req.getPath()); + assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); + assertEquals(FAKE_DATA, req.getBody().readUtf8()); + } + } + + @Test + public void sdkKeyIsSentForAnalytics() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + } + + RecordedRequest req = server.takeRequest(); + assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); + } + } + + @Test + public void sdkKeyIsSentForDiagnostics() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + } + + RecordedRequest req = server.takeRequest(); + assertThat(req.getHeader("Authorization"), equalTo(SDK_KEY)); + } + } + + @Test + public void eventSchemaIsSentForAnalytics() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + } + + RecordedRequest req = server.takeRequest(); + assertThat(req.getHeader("X-LaunchDarkly-Event-Schema"), equalTo("3")); + } + } + + @Test + public void eventPayloadIdIsSentForAnalytics() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + } + + RecordedRequest req = server.takeRequest(); + String payloadHeaderValue = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(payloadHeaderValue, notNullValue(String.class)); + assertThat(UUID.fromString(payloadHeaderValue), notNullValue(UUID.class)); + } + } + + @Test + public void eventPayloadIdReusedOnRetry() throws Exception { + MockResponse errorResponse = new MockResponse().setResponseCode(429); + + try (MockWebServer server = makeStartedServer(errorResponse, eventsSuccessResponse(), eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + } + + // Failed response request + RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); + String payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); + // Retry request has same payload ID as failed request + req = server.takeRequest(0, TimeUnit.SECONDS); + String retryId = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(retryId, equalTo(payloadId)); + // Second request has different payload ID from first request + req = server.takeRequest(0, TimeUnit.SECONDS); + payloadId = req.getHeader("X-LaunchDarkly-Payload-ID"); + assertThat(retryId, not(equalTo(payloadId))); + } + } + + @Test + public void eventSchemaNotSetOnDiagnosticEvents() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + } + + RecordedRequest req = server.takeRequest(); + assertNull(req.getHeader("X-LaunchDarkly-Event-Schema")); + } + } + + @Test + public void wrapperHeaderSentWhenSet() throws Exception { + HttpConfiguration httpConfig = Components.httpConfiguration() + .wrapper("Scala", "0.1.0") + .createHttpConfiguration(); + + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender(httpConfig)) { + es.sendEventData(ANALYTICS, FAKE_DATA, 1, getBaseUri(server)); + } + + RecordedRequest req = server.takeRequest(); + assertThat(req.getHeader("X-LaunchDarkly-Wrapper"), equalTo("Scala/0.1.0")); + } + } + + @Test + public void http400ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(400); + } + + @Test + public void http401ErrorIsUnrecoverable() throws Exception { + testUnrecoverableHttpError(401); + } + + @Test + public void http403ErrorIsUnrecoverable() throws Exception { + testUnrecoverableHttpError(403); + } + + // Cannot test our retry logic for 408, because OkHttp insists on doing its own retry on 408 so that + // we never actually see that response status. +// @Test +// public void http408ErrorIsRecoverable() throws Exception { +// testRecoverableHttpError(408); +// } + + @Test + public void http429ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(429); + } + + @Test + public void http500ErrorIsRecoverable() throws Exception { + testRecoverableHttpError(500); + } + + @Test + public void serverDateIsParsed() throws Exception { + long fakeTime = ((new Date().getTime() - 100000) / 1000) * 1000; // don't expect millisecond precision + MockResponse resp = addDateHeader(eventsSuccessResponse(), new Date(fakeTime)); + + try (MockWebServer server = makeStartedServer(resp)) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + + assertNotNull(result.getTimeFromServer()); + assertEquals(fakeTime, result.getTimeFromServer().getTime()); + } + } + } + + @Test + public void httpClientDoesNotAllowSelfSignedCertByDefault() throws Exception { + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, serverWithCert.uri()); + + assertFalse(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + assertEquals(0, serverWithCert.server.getRequestCount()); + } + } + + @Test + public void httpClientCanUseCustomTlsConfig() throws Exception { + try (TestHttpUtil.ServerWithCert serverWithCert = httpsServerWithSelfSignedCert(eventsSuccessResponse())) { + HttpConfiguration httpConfig = Components.httpConfiguration() + .sslSocketFactory(serverWithCert.socketFactory, serverWithCert.trustManager) + // allows us to trust the self-signed cert + .createHttpConfiguration(); + + try (EventSender es = makeEventSender(httpConfig)) { + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, serverWithCert.uri()); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + assertEquals(1, serverWithCert.server.getRequestCount()); + } + } + + private void testUnrecoverableHttpError(int status) throws Exception { + MockResponse errorResponse = new MockResponse().setResponseCode(status); + + try (MockWebServer server = makeStartedServer(errorResponse)) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + + assertFalse(result.isSuccess()); + assertTrue(result.isMustShutDown()); + } + + RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, notNullValue(RecordedRequest.class)); // this was the initial request that received the error + + // it does not retry after this type of error, so there are no more requests + assertThat(server.takeRequest(0, TimeUnit.SECONDS), nullValue(RecordedRequest.class)); + } + } + + private void testRecoverableHttpError(int status) throws Exception { + MockResponse errorResponse = new MockResponse().setResponseCode(status); + + // send two errors in a row, because the flush will be retried one time + try (MockWebServer server = makeStartedServer(errorResponse, errorResponse, eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + EventSender.Result result = es.sendEventData(DIAGNOSTICS, FAKE_DATA, 1, getBaseUri(server)); + + assertFalse(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RecordedRequest req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, notNullValue(RecordedRequest.class)); + req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, notNullValue(RecordedRequest.class)); + req = server.takeRequest(0, TimeUnit.SECONDS); + assertThat(req, nullValue(RecordedRequest.class)); // only 2 requests total + } + } + + private MockResponse eventsSuccessResponse() { + return new MockResponse().setResponseCode(202); + } + + private MockResponse addDateHeader(MockResponse response, Date date) { + return response.addHeader("Date", httpDateFormat.format(date)); + } + +} diff --git a/src/test/java/com/launchdarkly/client/TestUtil.java b/src/test/java/com/launchdarkly/client/TestUtil.java index 98663931d..64522e518 100644 --- a/src/test/java/com/launchdarkly/client/TestUtil.java +++ b/src/test/java/com/launchdarkly/client/TestUtil.java @@ -301,14 +301,24 @@ protected boolean matchesSafely(JsonElement item, Description mismatchDescriptio }; } - static EventsConfiguration makeEventsConfig(boolean allAttributesPrivate, boolean inlineUsersInEvents, - Set privateAttrNames) { + static EventsConfiguration makeEventsConfig( + boolean allAttributesPrivate, + boolean inlineUsersInEvents, + Set privateAttrNames + ) { return new EventsConfiguration( allAttributesPrivate, - 0, null, 0, + 0, + null, + null, + 0, inlineUsersInEvents, privateAttrNames, - 0, 0, 0, EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS); + 0, + 0, + 0, + EventProcessorBuilder.DEFAULT_DIAGNOSTIC_RECORDING_INTERVAL_SECONDS + ); } static EventsConfiguration defaultEventsConfig() { From 8822060310ae8175a79b937a4aeb57688ba51ae4 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 8 May 2020 19:01:34 -0700 Subject: [PATCH 304/327] add end-to-end test for events --- .../client/LDClientEndToEndTest.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index 355090516..7c9fa62d1 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -6,17 +6,20 @@ import org.junit.Test; +import static com.launchdarkly.client.Components.externalUpdatesOnly; import static com.launchdarkly.client.Components.noEvents; import static com.launchdarkly.client.TestHttpUtil.basePollingConfig; import static com.launchdarkly.client.TestHttpUtil.baseStreamingConfig; import static com.launchdarkly.client.TestHttpUtil.httpsServerWithSelfSignedCert; import static com.launchdarkly.client.TestHttpUtil.jsonResponse; import static com.launchdarkly.client.TestHttpUtil.makeStartedServer; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; @SuppressWarnings("javadoc") public class LDClientEndToEndTest { @@ -138,6 +141,46 @@ public void clientStartsInStreamingModeWithSelfSignedCert() throws Exception { } } + @Test + public void clientSendsAnalyticsEvent() throws Exception { + MockResponse resp = new MockResponse().setResponseCode(202); + + try (MockWebServer server = makeStartedServer(resp)) { + LDConfig config = new LDConfig.Builder() + .dataSource(externalUpdatesOnly()) + .events(Components.sendEvents().baseURI(server.url("/").uri())) + .diagnosticOptOut(true) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.initialized()); + client.identify(new LDUser("userkey")); + } + + RecordedRequest req = server.takeRequest(); + assertEquals("/bulk", req.getPath()); + } + } + + @Test + public void clientSendsDiagnosticEvent() throws Exception { + MockResponse resp = new MockResponse().setResponseCode(202); + + try (MockWebServer server = makeStartedServer(resp)) { + LDConfig config = new LDConfig.Builder() + .dataSource(externalUpdatesOnly()) + .events(Components.sendEvents().baseURI(server.url("/").uri())) + .build(); + + try (LDClient client = new LDClient(sdkKey, config)) { + assertTrue(client.initialized()); + } + + RecordedRequest req = server.takeRequest(); + assertEquals("/diagnostic", req.getPath()); + } + } + public String makeAllDataJson() { JsonObject flagsData = new JsonObject(); flagsData.add(flagKey, gson.toJsonTree(flag)); From 40d45fab1167f7ed9beafb91350847fd6b274af8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 May 2020 15:54:40 -0700 Subject: [PATCH 305/327] fix swapped log levels --- src/main/java/com/launchdarkly/client/DefaultEventSender.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventSender.java b/src/main/java/com/launchdarkly/client/DefaultEventSender.java index 844fbd950..e36c6ae62 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventSender.java @@ -113,9 +113,9 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI String logMessage = httpErrorMessage(response.code(), "posting " + description, nextActionMessage); if (isHttpErrorRecoverable(response.code())) { - logger.error(logMessage); - } else { logger.warn(logMessage); + } else { + logger.error(logMessage); mustShutDown = true; break; } From 6bd5afe2fb9d5a2df46cf63d88d5c31915d2fd2e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 May 2020 15:56:23 -0700 Subject: [PATCH 306/327] better synchronization usage --- .../java/com/launchdarkly/client/DefaultEventSender.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventSender.java b/src/main/java/com/launchdarkly/client/DefaultEventSender.java index e36c6ae62..bce400cce 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventSender.java @@ -34,7 +34,8 @@ final class DefaultEventSender implements EventSender { private static final String EVENT_SCHEMA_VERSION = "3"; private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; private static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); - + private static final Object HTTP_DATE_FORMAT_LOCK = new Object(); // synchronize on this because DateFormat isn't thread-safe + private final OkHttpClient httpClient; private final Headers baseHeaders; @@ -133,7 +134,7 @@ private static final Date parseResponseDate(Response response) { if (dateStr != null) { try { // DateFormat is not thread-safe, so must synchronize - synchronized (HTTP_DATE_FORMAT) { + synchronized (HTTP_DATE_FORMAT_LOCK) { return HTTP_DATE_FORMAT.parse(dateStr); } } catch (ParseException e) { From fcd963138792da834943cf4fdc6879e58d531219 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 May 2020 15:59:05 -0700 Subject: [PATCH 307/327] don't need to wait 1 second during tests --- .../com/launchdarkly/client/DefaultEventSender.java | 12 ++++++++---- .../launchdarkly/client/DefaultEventSenderTest.java | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventSender.java b/src/main/java/com/launchdarkly/client/DefaultEventSender.java index bce400cce..8b8b4996b 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventSender.java @@ -35,13 +35,16 @@ final class DefaultEventSender implements EventSender { private static final String EVENT_PAYLOAD_ID_HEADER = "X-LaunchDarkly-Payload-ID"; private static final SimpleDateFormat HTTP_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); private static final Object HTTP_DATE_FORMAT_LOCK = new Object(); // synchronize on this because DateFormat isn't thread-safe + static final int DEFAULT_RETRY_DELAY_MILLIS = 1000; private final OkHttpClient httpClient; private final Headers baseHeaders; + private final int retryDelayMillis; DefaultEventSender( String sdkKey, - HttpConfiguration httpConfiguration + HttpConfiguration httpConfiguration, + int retryDelayMillis ) { OkHttpClient.Builder httpBuilder = new OkHttpClient.Builder(); configureHttpClientBuilder(httpConfiguration, httpBuilder); @@ -51,6 +54,7 @@ final class DefaultEventSender implements EventSender { .add("Content-Type", "application/json") .build(); + this.retryDelayMillis = retryDelayMillis <= 0 ? DEFAULT_RETRY_DELAY_MILLIS : retryDelayMillis; } @Override @@ -88,9 +92,9 @@ public Result sendEventData(EventDataKind kind, String data, int eventCount, URI for (int attempt = 0; attempt < 2; attempt++) { if (attempt > 0) { - logger.warn("Will retry posting {} after 1 second", description); + logger.warn("Will retry posting {} after {} milliseconds", description, retryDelayMillis); try { - Thread.sleep(1000); + Thread.sleep(retryDelayMillis); } catch (InterruptedException e) { } } @@ -147,7 +151,7 @@ private static final Date parseResponseDate(Response response) { static final class Factory implements EventSenderFactory { @Override public EventSender createEventSender(String sdkKey, HttpConfiguration httpConfiguration) { - return new DefaultEventSender(sdkKey, httpConfiguration); + return new DefaultEventSender(sdkKey, httpConfiguration, DefaultEventSender.DEFAULT_RETRY_DELAY_MILLIS); } } } diff --git a/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java index 61425d466..de3d14bc8 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java @@ -35,6 +35,7 @@ public class DefaultEventSenderTest { private static final String SDK_KEY = "SDK_KEY"; private static final String FAKE_DATA = "some data"; private static final SimpleDateFormat httpDateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz"); + private static final int BRIEF_RETRY_DELAY_MILLIS = 50; private static EventSender makeEventSender() { return makeEventSender(Components.httpConfiguration().createHttpConfiguration()); @@ -43,7 +44,8 @@ private static EventSender makeEventSender() { private static EventSender makeEventSender(HttpConfiguration httpConfiguration) { return new DefaultEventSender( SDK_KEY, - httpConfiguration + httpConfiguration, + BRIEF_RETRY_DELAY_MILLIS ); } From 8a9d9dd1546b0d6958f9871f9962ff41e3444aab Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 12 May 2020 20:05:24 -0700 Subject: [PATCH 308/327] make events URI construction reliable regardless of whether base URI ends in a slash --- .../client/DefaultEventSender.java | 7 ++++--- .../client/DefaultEventSenderTest.java | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/DefaultEventSender.java b/src/main/java/com/launchdarkly/client/DefaultEventSender.java index 8b8b4996b..8a7f30613 100644 --- a/src/main/java/com/launchdarkly/client/DefaultEventSender.java +++ b/src/main/java/com/launchdarkly/client/DefaultEventSender.java @@ -65,25 +65,26 @@ public void close() throws IOException { @Override public Result sendEventData(EventDataKind kind, String data, int eventCount, URI eventsBaseUri) { Headers.Builder headersBuilder = baseHeaders.newBuilder(); - URI uri; + String path; String description; switch (kind) { case ANALYTICS: - uri = eventsBaseUri.resolve("bulk"); + path = "bulk"; String eventPayloadId = UUID.randomUUID().toString(); headersBuilder.add(EVENT_PAYLOAD_ID_HEADER, eventPayloadId); headersBuilder.add(EVENT_SCHEMA_HEADER, EVENT_SCHEMA_VERSION); description = String.format("%d event(s)", eventCount); break; case DIAGNOSTICS: - uri = eventsBaseUri.resolve("diagnostic"); + path = "diagnostic"; description = "diagnostic event"; break; default: throw new IllegalArgumentException("kind"); } + URI uri = eventsBaseUri.resolve(eventsBaseUri.getPath().endsWith("/") ? path : ("/" + path)); Headers headers = headersBuilder.build(); RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), data); boolean mustShutDown = false; diff --git a/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java b/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java index de3d14bc8..994a126f6 100644 --- a/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java +++ b/src/test/java/com/launchdarkly/client/DefaultEventSenderTest.java @@ -269,6 +269,24 @@ public void httpClientCanUseCustomTlsConfig() throws Exception { } } + @Test + public void baseUriDoesNotNeedToEndInSlash() throws Exception { + try (MockWebServer server = makeStartedServer(eventsSuccessResponse())) { + try (EventSender es = makeEventSender()) { + URI uriWithoutSlash = URI.create(server.url("/").toString().replaceAll("/$", "")); + EventSender.Result result = es.sendEventData(ANALYTICS, FAKE_DATA, 1, uriWithoutSlash); + + assertTrue(result.isSuccess()); + assertFalse(result.isMustShutDown()); + } + + RecordedRequest req = server.takeRequest(); + assertEquals("/bulk", req.getPath()); + assertEquals("application/json; charset=utf-8", req.getHeader("content-type")); + assertEquals(FAKE_DATA, req.getBody().readUtf8()); + } + } + private void testUnrecoverableHttpError(int status) throws Exception { MockResponse errorResponse = new MockResponse().setResponseCode(status); From bc090575c069c1d1c2579da59d3ca82566502fa3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 May 2020 12:37:25 -0700 Subject: [PATCH 309/327] fix race condition in LDClientEndToEndTest --- .../com/launchdarkly/client/LDClientEndToEndTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java index 7c9fa62d1..55d0c1b05 100644 --- a/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java +++ b/src/test/java/com/launchdarkly/client/LDClientEndToEndTest.java @@ -174,10 +174,10 @@ public void clientSendsDiagnosticEvent() throws Exception { try (LDClient client = new LDClient(sdkKey, config)) { assertTrue(client.initialized()); - } - - RecordedRequest req = server.takeRequest(); - assertEquals("/diagnostic", req.getPath()); + + RecordedRequest req = server.takeRequest(); + assertEquals("/diagnostic", req.getPath()); + } } } From ab99c8240f0a7040c76e473b2574c486cc36b3d9 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 19 May 2020 16:35:36 -0700 Subject: [PATCH 310/327] more benchmark implementation --- .circleci/config.yml | 2 +- benchmarks/Makefile | 4 +- benchmarks/build.gradle | 4 +- .../client/EventProcessorBenchmarks.java | 142 ++++++++++++++++++ .../client/LDClientEvaluationBenchmarks.java | 32 +++- .../com/launchdarkly/client/TestValues.java | 24 +++ 6 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorBenchmarks.java diff --git a/.circleci/config.yml b/.circleci/config.yml index c24a5da9c..9d09ffa0d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -137,7 +137,7 @@ jobs: benchmarks: docker: - - image: circleci/openjdk:8 + - image: circleci/openjdk:11 steps: - run: java -version - run: sudo apt-get install make -y -q diff --git a/benchmarks/Makefile b/benchmarks/Makefile index 6597fb21d..dfe0cae8e 100644 --- a/benchmarks/Makefile +++ b/benchmarks/Makefile @@ -1,4 +1,4 @@ -.PHONY: benchmark clean +.PHONY: benchmark clean sdk BASE_DIR:=$(shell pwd) PROJECT_DIR=$(shell cd .. && pwd) @@ -17,6 +17,8 @@ benchmark: $(BENCHMARK_ALL_JAR) $(BENCHMARK_TEST_JAR) clean: rm -rf build lib +sdk: $(BENCHMARK_ALL_JAR) $(BENCHMARK_TEST_JAR) + $(BENCHMARK_ALL_JAR): $(SDK_ALL_JAR) mkdir -p lib cp $< $@ diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 3f54a72e4..b93753271 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -15,7 +15,8 @@ repositories { } ext.versions = [ - "jmh": "1.21" + "jmh": "1.21", + "guava": "19.0" ] dependencies { @@ -27,6 +28,7 @@ dependencies { // the "compile" configuration isn't used when running benchmarks, but it allows us to compile the code in an IDE compile files("lib/launchdarkly-java-server-sdk-all.jar") compile files("lib/launchdarkly-java-server-sdk-test.jar") + compile "com.google.guava:guava:${versions.guava}" // required by SDK test code compile "com.squareup.okhttp3:mockwebserver:3.12.10" // compile "org.hamcrest:hamcrest-all:1.3" compile "org.openjdk.jmh:jmh-core:1.21" diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorBenchmarks.java new file mode 100644 index 000000000..be73a2389 --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorBenchmarks.java @@ -0,0 +1,142 @@ +package com.launchdarkly.client; + +import com.launchdarkly.client.interfaces.EventSender; +import com.launchdarkly.client.interfaces.EventSenderFactory; +import com.launchdarkly.client.interfaces.HttpConfiguration; +import com.launchdarkly.client.value.LDValue; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; + +import java.io.IOException; +import java.net.URI; +import java.util.Random; + +public class EventProcessorBenchmarks { + private static final int EVENT_BUFFER_SIZE = 1000; + private static final int FLAG_COUNT = 10; + private static final int FLAG_VERSIONS = 3; + private static final int FLAG_VARIATIONS = 2; + + @State(Scope.Thread) + public static class BenchmarkInputs { + // Initialization of the things in BenchmarkInputs does not count as part of a benchmark. + final DefaultEventProcessor eventProcessor; + final EventSender eventSender; + final LDUser basicUser; + final Random random; + + public BenchmarkInputs() { + // MockEventSender does no I/O - it discards every event payload. So we are benchmarking + // all of the event processing steps up to that point, including the formatting of the + // JSON data in the payload. + eventSender = new MockEventSender(); + + eventProcessor = (DefaultEventProcessor)Components.sendEvents() + .capacity(EVENT_BUFFER_SIZE) + .eventSender(new MockEventSenderFactory()) + .createEventProcessor(TestValues.SDK_KEY, LDConfig.DEFAULT); + + basicUser = new LDUser("userkey"); + + random = new Random(); + } + + public String randomFlagKey() { + return "flag" + random.nextInt(FLAG_COUNT); + } + + public int randomFlagVersion() { + return random.nextInt(FLAG_VERSIONS) + 1; + } + + public int randomFlagVariation() { + return random.nextInt(FLAG_VARIATIONS); + } + } + + @Benchmark + public void summarizeFeatureRequestEvents(BenchmarkInputs inputs) throws Exception { + for (int i = 0; i < 1000; i++) { + int variation = inputs.randomFlagVariation(); + Event.FeatureRequest event = new Event.FeatureRequest( + System.currentTimeMillis(), + inputs.randomFlagKey(), + inputs.basicUser, + inputs.randomFlagVersion(), + variation, + LDValue.of(variation), + LDValue.ofNull(), + null, + null, + false, // trackEvents == false: only summary counts are generated + null, + false + ); + inputs.eventProcessor.sendEvent(event); + } + inputs.eventProcessor.flush(); + inputs.eventProcessor.waitUntilInactive(); + } + + @Benchmark + public void featureRequestEventsWithFullTracking(BenchmarkInputs inputs) throws Exception { + for (int i = 0; i < 1000; i++) { + int variation = inputs.randomFlagVariation(); + Event.FeatureRequest event = new Event.FeatureRequest( + System.currentTimeMillis(), + inputs.randomFlagKey(), + inputs.basicUser, + inputs.randomFlagVersion(), + variation, + LDValue.of(variation), + LDValue.ofNull(), + null, + null, + true, // trackEvents == true: the full events are included in the output + null, + false + ); + inputs.eventProcessor.sendEvent(event); + } + inputs.eventProcessor.flush(); + inputs.eventProcessor.waitUntilInactive(); + } + + @Benchmark + public void customEvents(BenchmarkInputs inputs) throws Exception { + LDValue data = LDValue.of("data"); + for (int i = 0; i < 1000; i++) { + Event.Custom event = new Event.Custom( + System.currentTimeMillis(), + "event-key", + inputs.basicUser, + data, + null + ); + inputs.eventProcessor.sendEvent(event);; + } + inputs.eventProcessor.flush(); + inputs.eventProcessor.waitUntilInactive(); + } + + private static final class MockEventSender implements EventSender { + private static final Result RESULT = new Result(true, false, null); + + @Override + public void close() throws IOException {} + + @Override + public Result sendEventData(EventDataKind arg0, String arg1, int arg2, URI arg3) { + return RESULT; + } + } + + private static final class MockEventSenderFactory implements EventSenderFactory { + @Override + public EventSender createEventSender(String arg0, HttpConfiguration arg1) { + return new MockEventSender(); + } + } +} diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java index e684a1fe7..a27efb6e5 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java @@ -9,10 +9,14 @@ import java.util.Random; import static com.launchdarkly.client.TestValues.BOOLEAN_FLAG_KEY; +import static com.launchdarkly.client.TestValues.CLAUSE_MATCH_ATTRIBUTE; +import static com.launchdarkly.client.TestValues.CLAUSE_MATCH_VALUES; +import static com.launchdarkly.client.TestValues.FLAG_WITH_MULTI_VALUE_CLAUSE_KEY; import static com.launchdarkly.client.TestValues.FLAG_WITH_PREREQ_KEY; import static com.launchdarkly.client.TestValues.FLAG_WITH_TARGET_LIST_KEY; import static com.launchdarkly.client.TestValues.INT_FLAG_KEY; import static com.launchdarkly.client.TestValues.JSON_FLAG_KEY; +import static com.launchdarkly.client.TestValues.NOT_MATCHED_VALUE; import static com.launchdarkly.client.TestValues.NOT_TARGETED_USER_KEY; import static com.launchdarkly.client.TestValues.SDK_KEY; import static com.launchdarkly.client.TestValues.STRING_FLAG_KEY; @@ -20,6 +24,8 @@ import static com.launchdarkly.client.TestValues.UNKNOWN_FLAG_KEY; import static com.launchdarkly.client.TestValues.makeTestFlags; import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; /** * These benchmarks cover just the evaluation logic itself (and, by necessity, the overhead of getting the @@ -28,7 +34,7 @@ public class LDClientEvaluationBenchmarks { @State(Scope.Thread) public static class BenchmarkInputs { - // Initialization of the things in BenchmarkInputs do not count as part of a benchmark. + // Initialization of the things in BenchmarkInputs does not count as part of a benchmark. final LDClientInterface client; final LDUser basicUser; final Random random; @@ -115,16 +121,34 @@ public void jsonVariationForUnknownFlag(BenchmarkInputs inputs) throws Exception @Benchmark public void userFoundInTargetList(BenchmarkInputs inputs) throws Exception { String userKey = TARGETED_USER_KEYS.get(inputs.random.nextInt(TARGETED_USER_KEYS.size())); - inputs.client.boolVariation(FLAG_WITH_TARGET_LIST_KEY, new LDUser(userKey), false); + boolean result = inputs.client.boolVariation(FLAG_WITH_TARGET_LIST_KEY, new LDUser(userKey), false); + assertTrue(result); } @Benchmark public void userNotFoundInTargetList(BenchmarkInputs inputs) throws Exception { - inputs.client.boolVariation(FLAG_WITH_TARGET_LIST_KEY, new LDUser(NOT_TARGETED_USER_KEY), false); + boolean result = inputs.client.boolVariation(FLAG_WITH_TARGET_LIST_KEY, new LDUser(NOT_TARGETED_USER_KEY), false); + assertFalse(result); } @Benchmark public void flagWithPrerequisite(BenchmarkInputs inputs) throws Exception { - inputs.client.boolVariation(FLAG_WITH_PREREQ_KEY, inputs.basicUser, false); + boolean result = inputs.client.boolVariation(FLAG_WITH_PREREQ_KEY, inputs.basicUser, false); + assertTrue(result); + } + + @Benchmark + public void userValueFoundInClauseList(BenchmarkInputs inputs) throws Exception { + LDValue userValue = CLAUSE_MATCH_VALUES.get(inputs.random.nextInt(CLAUSE_MATCH_VALUES.size())); + LDUser user = new LDUser.Builder("key").custom(CLAUSE_MATCH_ATTRIBUTE, userValue).build(); + boolean result = inputs.client.boolVariation(FLAG_WITH_MULTI_VALUE_CLAUSE_KEY, user, false); + assertTrue(result); + } + + @Benchmark + public void userValueNotFoundInClauseList(BenchmarkInputs inputs) throws Exception { + LDUser user = new LDUser.Builder("key").custom(CLAUSE_MATCH_ATTRIBUTE, NOT_MATCHED_VALUE).build(); + boolean result = inputs.client.boolVariation(FLAG_WITH_MULTI_VALUE_CLAUSE_KEY, user, false); + assertFalse(result); } } diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java b/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java index 0c2e12098..fc7e41828 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java @@ -21,6 +21,7 @@ private TestValues() {} public static final String JSON_FLAG_KEY = "flag-json"; public static final String FLAG_WITH_TARGET_LIST_KEY = "flag-with-targets"; public static final String FLAG_WITH_PREREQ_KEY = "flag-with-prereq"; + public static final String FLAG_WITH_MULTI_VALUE_CLAUSE_KEY = "flag-with-multi-value-clause"; public static final String UNKNOWN_FLAG_KEY = "no-such-flag"; public static final List TARGETED_USER_KEYS; @@ -32,6 +33,16 @@ private TestValues() {} } public static final String NOT_TARGETED_USER_KEY = "no-match"; + public static final String CLAUSE_MATCH_ATTRIBUTE = "clause-match-attr"; + public static final List CLAUSE_MATCH_VALUES; + static { + CLAUSE_MATCH_VALUES = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + CLAUSE_MATCH_VALUES.add(LDValue.of("value-" + i)); + } + } + public static final LDValue NOT_MATCHED_VALUE = LDValue.of("no-match"); + public static final String EMPTY_JSON_DATA = "{\"flags\":{},\"segments\":{}}"; public static List makeTestFlags() { @@ -67,6 +78,19 @@ public static List makeTestFlags() { .build(); flags.add(flagWithPrereq); + FeatureFlag flagWithMultiValueClause = new FeatureFlagBuilder(FLAG_WITH_MULTI_VALUE_CLAUSE_KEY) + .on(true) + .fallthrough(fallthroughVariation(0)) + .offVariation(0) + .variations(LDValue.of(false), LDValue.of(true)) + .rules(Arrays.asList( + new RuleBuilder() + .clauses(new Clause(CLAUSE_MATCH_ATTRIBUTE, Operator.in, CLAUSE_MATCH_VALUES, false)) + .build() + )) + .build(); + flags.add(flagWithMultiValueClause); + return flags; } } From 631d8729a52ec406ff4486d7861555ba5e1e7c17 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 16:48:30 -0700 Subject: [PATCH 311/327] note about benchmarks in CONTRIBUTING --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e5c76bf0..c55110cc4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,3 +42,7 @@ To build the SDK and run all unit tests: ``` By default, the full unit test suite includes live tests of the Redis integration. Those tests expect you to have Redis running locally. To skip them, set the environment variable `LD_SKIP_DATABASE_TESTS=1` before running the tests. + +## Benchmarks + +The project in the `benchmarks` subdirectory uses [JMH](https://openjdk.java.net/projects/code-tools/jmh/) to generate performance metrics for the SDK. This is run as a CI job, and can also be run manually by running `make` within `benchmarks` and then inspecting `build/reports/jmh`. From a1de46ce89fdf835c824a7d7cb2f822bde8309f7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 17:04:28 -0700 Subject: [PATCH 312/327] rm unused --- benchmarks/build.gradle | 7 ------- 1 file changed, 7 deletions(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index b93753271..1f10e9201 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -20,17 +20,10 @@ ext.versions = [ ] dependencies { - // jmh files("lib/launchdarkly-java-server-sdk-all.jar") - // jmh files("lib/launchdarkly-java-server-sdk-test.jar") - // jmh "com.squareup.okhttp3:mockwebserver:3.12.10" - //jmh "org.hamcrest:hamcrest-all:1.3" // the benchmarks don't use this, but the test jar has references to it - - // the "compile" configuration isn't used when running benchmarks, but it allows us to compile the code in an IDE compile files("lib/launchdarkly-java-server-sdk-all.jar") compile files("lib/launchdarkly-java-server-sdk-test.jar") compile "com.google.guava:guava:${versions.guava}" // required by SDK test code compile "com.squareup.okhttp3:mockwebserver:3.12.10" - // compile "org.hamcrest:hamcrest-all:1.3" compile "org.openjdk.jmh:jmh-core:1.21" compile "org.openjdk.jmh:jmh-generator-annprocess:${versions.jmh}" } From 6565cccf4e817c7b00a503f55e4c1be9a840c2a7 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 17:07:13 -0700 Subject: [PATCH 313/327] formatting --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c55110cc4..13daea7e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,6 @@ To build the SDK and run all unit tests: By default, the full unit test suite includes live tests of the Redis integration. Those tests expect you to have Redis running locally. To skip them, set the environment variable `LD_SKIP_DATABASE_TESTS=1` before running the tests. -## Benchmarks +### Benchmarks The project in the `benchmarks` subdirectory uses [JMH](https://openjdk.java.net/projects/code-tools/jmh/) to generate performance metrics for the SDK. This is run as a CI job, and can also be run manually by running `make` within `benchmarks` and then inspecting `build/reports/jmh`. From 87163c757a050e9bcabcc25f217fab2fb9c8b0cb Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 17:12:08 -0700 Subject: [PATCH 314/327] use JMH average time mode, not throughput --- benchmarks/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 1f10e9201..ab072a080 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -30,7 +30,7 @@ dependencies { jmh { iterations = 10 // Number of measurement iterations to do. - benchmarkMode = ['thrpt'] + benchmarkMode = ['avgt'] // "average time" - reports execution time as ns/op and allocations as B/op. // batchSize = 1 // Batch size: number of benchmark method calls per operation. (some benchmark modes can ignore this setting) fork = 1 // How many times to forks a single benchmark. Use 0 to disable forking altogether // failOnError = false // Should JMH fail immediately if any benchmark had experienced the unrecoverable error? From 0358ae945080fc767edd86b4401b67c3b1da731e Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 17:21:36 -0700 Subject: [PATCH 315/327] measure in nanoseconds --- benchmarks/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index ab072a080..50fb95ef1 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -45,7 +45,7 @@ jmh { // synchronizeIterations = false // Synchronize iterations? // threads = 4 // Number of worker threads to run with. // timeout = '1s' // Timeout for benchmark iteration. - // timeUnit = 'ms' // Output time unit. Available time units are: [m, s, ms, us, ns]. + timeUnit = 'ns' // Output time unit. Available time units are: [m, s, ms, us, ns]. // verbosity = 'EXTRA' // Verbosity mode. Available modes are: [SILENT, NORMAL, EXTRA] warmup = '1s' // Time to spend at each warmup iteration. warmupBatchSize = 2 // Warmup batch size: number of benchmark method calls per operation. From 4f8273fcba782d5132aa9a8a179522420ca30fef Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 17:55:04 -0700 Subject: [PATCH 316/327] move benchmark code to 5.0-like packages for easier cross-comparison --- benchmarks/build.gradle | 2 +- .../client/EventProcessorInternals.java | 14 ++++++ .../client/{TestValues.java => FlagData.java} | 50 +++++++------------ .../server}/EventProcessorBenchmarks.java | 20 +++++--- .../server}/LDClientEvaluationBenchmarks.java | 46 +++++++++-------- .../launchdarkly/sdk/server/TestValues.java | 42 ++++++++++++++++ 6 files changed, 113 insertions(+), 61 deletions(-) create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorInternals.java rename benchmarks/src/jmh/java/com/launchdarkly/client/{TestValues.java => FlagData.java} (60%) rename benchmarks/src/jmh/java/com/launchdarkly/{client => sdk/server}/EventProcessorBenchmarks.java (85%) rename benchmarks/src/jmh/java/com/launchdarkly/{client => sdk/server}/LDClientEvaluationBenchmarks.java (77%) create mode 100644 benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 50fb95ef1..c59a18a65 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -41,7 +41,7 @@ jmh { // benchmarkParameters = [:] // Benchmark parameters. profilers = [ 'gc' ] // Use profilers to collect additional data. Supported profilers: [cl, comp, gc, stack, perf, perfnorm, perfasm, xperf, xperfasm, hs_cl, hs_comp, hs_gc, hs_rt, hs_thr] timeOnIteration = '1s' // Time to spend at each measurement iteration. - // resultFormat = 'CSV' // Result format type (one of CSV, JSON, NONE, SCSV, TEXT) + resultFormat = 'JSON' // Result format type (one of CSV, JSON, NONE, SCSV, TEXT) // synchronizeIterations = false // Synchronize iterations? // threads = 4 // Number of worker threads to run with. // timeout = '1s' // Timeout for benchmark iteration. diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorInternals.java b/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorInternals.java new file mode 100644 index 000000000..416ff2d44 --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorInternals.java @@ -0,0 +1,14 @@ +package com.launchdarkly.client; + +import java.io.IOException; + +// Placed here so we can access package-private SDK methods. +public class EventProcessorInternals { + public static void waitUntilInactive(EventProcessor ep) { + try { + ((DefaultEventProcessor)ep).waitUntilInactive(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java b/benchmarks/src/jmh/java/com/launchdarkly/client/FlagData.java similarity index 60% rename from benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java rename to benchmarks/src/jmh/java/com/launchdarkly/client/FlagData.java index fc7e41828..f908e80c3 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/client/TestValues.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/client/FlagData.java @@ -9,41 +9,27 @@ import static com.launchdarkly.client.TestUtil.fallthroughVariation; import static com.launchdarkly.client.TestUtil.flagWithValue; +import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.sdk.server.TestValues.BOOLEAN_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.CLAUSE_MATCH_ATTRIBUTE; +import static com.launchdarkly.sdk.server.TestValues.CLAUSE_MATCH_VALUES; +import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_MULTI_VALUE_CLAUSE_KEY; +import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_PREREQ_KEY; +import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_TARGET_LIST_KEY; +import static com.launchdarkly.sdk.server.TestValues.INT_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.JSON_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.STRING_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.TARGETED_USER_KEYS; -public abstract class TestValues { - private TestValues() {} - - public static final String SDK_KEY = "sdk-key"; - - public static final String BOOLEAN_FLAG_KEY = "flag-bool"; - public static final String INT_FLAG_KEY = "flag-int"; - public static final String STRING_FLAG_KEY = "flag-string"; - public static final String JSON_FLAG_KEY = "flag-json"; - public static final String FLAG_WITH_TARGET_LIST_KEY = "flag-with-targets"; - public static final String FLAG_WITH_PREREQ_KEY = "flag-with-prereq"; - public static final String FLAG_WITH_MULTI_VALUE_CLAUSE_KEY = "flag-with-multi-value-clause"; - public static final String UNKNOWN_FLAG_KEY = "no-such-flag"; - - public static final List TARGETED_USER_KEYS; - static { - TARGETED_USER_KEYS = new ArrayList<>(); - for (int i = 0; i < 1000; i++) { - TARGETED_USER_KEYS.add("user-" + i); +// This class must be in com.launchdarkly.client because FeatureFlagBuilder is package-private in the +// SDK, but we are keeping the rest of the benchmark implementation code in com.launchdarkly.sdk.server +// so we can more clearly compare between 4.x and 5.0. +public class FlagData { + public static void loadTestFlags(FeatureStore store) { + for (FeatureFlag flag: FlagData.makeTestFlags()) { + store.upsert(FEATURES, flag); } } - public static final String NOT_TARGETED_USER_KEY = "no-match"; - - public static final String CLAUSE_MATCH_ATTRIBUTE = "clause-match-attr"; - public static final List CLAUSE_MATCH_VALUES; - static { - CLAUSE_MATCH_VALUES = new ArrayList<>(); - for (int i = 0; i < 1000; i++) { - CLAUSE_MATCH_VALUES.add(LDValue.of("value-" + i)); - } - } - public static final LDValue NOT_MATCHED_VALUE = LDValue.of("no-match"); - - public static final String EMPTY_JSON_DATA = "{\"flags\":{},\"segments\":{}}"; public static List makeTestFlags() { List flags = new ArrayList<>(); diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java similarity index 85% rename from benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorBenchmarks.java rename to benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java index be73a2389..3195dae24 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/client/EventProcessorBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java @@ -1,5 +1,11 @@ -package com.launchdarkly.client; +package com.launchdarkly.sdk.server; +import com.launchdarkly.client.Components; +import com.launchdarkly.client.Event; +import com.launchdarkly.client.EventProcessor; +import com.launchdarkly.client.EventProcessorInternals; +import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.LDUser; import com.launchdarkly.client.interfaces.EventSender; import com.launchdarkly.client.interfaces.EventSenderFactory; import com.launchdarkly.client.interfaces.HttpConfiguration; @@ -22,7 +28,7 @@ public class EventProcessorBenchmarks { @State(Scope.Thread) public static class BenchmarkInputs { // Initialization of the things in BenchmarkInputs does not count as part of a benchmark. - final DefaultEventProcessor eventProcessor; + final EventProcessor eventProcessor; final EventSender eventSender; final LDUser basicUser; final Random random; @@ -33,10 +39,10 @@ public BenchmarkInputs() { // JSON data in the payload. eventSender = new MockEventSender(); - eventProcessor = (DefaultEventProcessor)Components.sendEvents() + eventProcessor = Components.sendEvents() .capacity(EVENT_BUFFER_SIZE) .eventSender(new MockEventSenderFactory()) - .createEventProcessor(TestValues.SDK_KEY, LDConfig.DEFAULT); + .createEventProcessor(TestValues.SDK_KEY, new LDConfig.Builder().build()); basicUser = new LDUser("userkey"); @@ -77,7 +83,7 @@ public void summarizeFeatureRequestEvents(BenchmarkInputs inputs) throws Excepti inputs.eventProcessor.sendEvent(event); } inputs.eventProcessor.flush(); - inputs.eventProcessor.waitUntilInactive(); + EventProcessorInternals.waitUntilInactive(inputs.eventProcessor); } @Benchmark @@ -101,7 +107,7 @@ public void featureRequestEventsWithFullTracking(BenchmarkInputs inputs) throws inputs.eventProcessor.sendEvent(event); } inputs.eventProcessor.flush(); - inputs.eventProcessor.waitUntilInactive(); + EventProcessorInternals.waitUntilInactive(inputs.eventProcessor); } @Benchmark @@ -118,7 +124,7 @@ public void customEvents(BenchmarkInputs inputs) throws Exception { inputs.eventProcessor.sendEvent(event);; } inputs.eventProcessor.flush(); - inputs.eventProcessor.waitUntilInactive(); + EventProcessorInternals.waitUntilInactive(inputs.eventProcessor); } private static final class MockEventSender implements EventSender { diff --git a/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java similarity index 77% rename from benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java rename to benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java index a27efb6e5..6066c0ebf 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/client/LDClientEvaluationBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java @@ -1,5 +1,13 @@ -package com.launchdarkly.client; - +package com.launchdarkly.sdk.server; + +import com.launchdarkly.client.Components; +import com.launchdarkly.client.FeatureStore; +import com.launchdarkly.client.FlagData; +import com.launchdarkly.client.LDClient; +import com.launchdarkly.client.LDClientInterface; +import com.launchdarkly.client.LDConfig; +import com.launchdarkly.client.LDUser; +import com.launchdarkly.client.TestUtil; import com.launchdarkly.client.value.LDValue; import org.openjdk.jmh.annotations.Benchmark; @@ -8,22 +16,20 @@ import java.util.Random; -import static com.launchdarkly.client.TestValues.BOOLEAN_FLAG_KEY; -import static com.launchdarkly.client.TestValues.CLAUSE_MATCH_ATTRIBUTE; -import static com.launchdarkly.client.TestValues.CLAUSE_MATCH_VALUES; -import static com.launchdarkly.client.TestValues.FLAG_WITH_MULTI_VALUE_CLAUSE_KEY; -import static com.launchdarkly.client.TestValues.FLAG_WITH_PREREQ_KEY; -import static com.launchdarkly.client.TestValues.FLAG_WITH_TARGET_LIST_KEY; -import static com.launchdarkly.client.TestValues.INT_FLAG_KEY; -import static com.launchdarkly.client.TestValues.JSON_FLAG_KEY; -import static com.launchdarkly.client.TestValues.NOT_MATCHED_VALUE; -import static com.launchdarkly.client.TestValues.NOT_TARGETED_USER_KEY; -import static com.launchdarkly.client.TestValues.SDK_KEY; -import static com.launchdarkly.client.TestValues.STRING_FLAG_KEY; -import static com.launchdarkly.client.TestValues.TARGETED_USER_KEYS; -import static com.launchdarkly.client.TestValues.UNKNOWN_FLAG_KEY; -import static com.launchdarkly.client.TestValues.makeTestFlags; -import static com.launchdarkly.client.VersionedDataKind.FEATURES; +import static com.launchdarkly.sdk.server.TestValues.BOOLEAN_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.CLAUSE_MATCH_ATTRIBUTE; +import static com.launchdarkly.sdk.server.TestValues.CLAUSE_MATCH_VALUES; +import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_MULTI_VALUE_CLAUSE_KEY; +import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_PREREQ_KEY; +import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_TARGET_LIST_KEY; +import static com.launchdarkly.sdk.server.TestValues.INT_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.JSON_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.NOT_MATCHED_VALUE; +import static com.launchdarkly.sdk.server.TestValues.NOT_TARGETED_USER_KEY; +import static com.launchdarkly.sdk.server.TestValues.SDK_KEY; +import static com.launchdarkly.sdk.server.TestValues.STRING_FLAG_KEY; +import static com.launchdarkly.sdk.server.TestValues.TARGETED_USER_KEYS; +import static com.launchdarkly.sdk.server.TestValues.UNKNOWN_FLAG_KEY; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -41,9 +47,7 @@ public static class BenchmarkInputs { public BenchmarkInputs() { FeatureStore featureStore = TestUtil.initedFeatureStore(); - for (FeatureFlag flag: makeTestFlags()) { - featureStore.upsert(FEATURES, flag); - } + FlagData.loadTestFlags(featureStore); LDConfig config = new LDConfig.Builder() .dataStore(TestUtil.specificFeatureStore(featureStore)) diff --git a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java new file mode 100644 index 000000000..65258c50c --- /dev/null +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java @@ -0,0 +1,42 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.client.value.LDValue; + +import java.util.ArrayList; +import java.util.List; + +public abstract class TestValues { + private TestValues() {} + + public static final String SDK_KEY = "sdk-key"; + + public static final String BOOLEAN_FLAG_KEY = "flag-bool"; + public static final String INT_FLAG_KEY = "flag-int"; + public static final String STRING_FLAG_KEY = "flag-string"; + public static final String JSON_FLAG_KEY = "flag-json"; + public static final String FLAG_WITH_TARGET_LIST_KEY = "flag-with-targets"; + public static final String FLAG_WITH_PREREQ_KEY = "flag-with-prereq"; + public static final String FLAG_WITH_MULTI_VALUE_CLAUSE_KEY = "flag-with-multi-value-clause"; + public static final String UNKNOWN_FLAG_KEY = "no-such-flag"; + + public static final List TARGETED_USER_KEYS; + static { + TARGETED_USER_KEYS = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + TARGETED_USER_KEYS.add("user-" + i); + } + } + public static final String NOT_TARGETED_USER_KEY = "no-match"; + + public static final String CLAUSE_MATCH_ATTRIBUTE = "clause-match-attr"; + public static final List CLAUSE_MATCH_VALUES; + static { + CLAUSE_MATCH_VALUES = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + CLAUSE_MATCH_VALUES.add(LDValue.of("value-" + i)); + } + } + public static final LDValue NOT_MATCHED_VALUE = LDValue.of("no-match"); + + public static final String EMPTY_JSON_DATA = "{\"flags\":{},\"segments\":{}}"; +} From c84cda9539e2931f051b295d331ef9e87b668ad8 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:20:06 -0700 Subject: [PATCH 317/327] move test data generation out of benchmarks --- .../sdk/server/LDClientEvaluationBenchmarks.java | 12 +++++------- .../com/launchdarkly/sdk/server/TestValues.java | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java index 6066c0ebf..2caa89a2e 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/LDClientEvaluationBenchmarks.java @@ -17,14 +17,13 @@ import java.util.Random; import static com.launchdarkly.sdk.server.TestValues.BOOLEAN_FLAG_KEY; -import static com.launchdarkly.sdk.server.TestValues.CLAUSE_MATCH_ATTRIBUTE; -import static com.launchdarkly.sdk.server.TestValues.CLAUSE_MATCH_VALUES; +import static com.launchdarkly.sdk.server.TestValues.CLAUSE_MATCH_VALUE_COUNT; import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_MULTI_VALUE_CLAUSE_KEY; import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_PREREQ_KEY; import static com.launchdarkly.sdk.server.TestValues.FLAG_WITH_TARGET_LIST_KEY; import static com.launchdarkly.sdk.server.TestValues.INT_FLAG_KEY; import static com.launchdarkly.sdk.server.TestValues.JSON_FLAG_KEY; -import static com.launchdarkly.sdk.server.TestValues.NOT_MATCHED_VALUE; +import static com.launchdarkly.sdk.server.TestValues.NOT_MATCHED_VALUE_USER; import static com.launchdarkly.sdk.server.TestValues.NOT_TARGETED_USER_KEY; import static com.launchdarkly.sdk.server.TestValues.SDK_KEY; import static com.launchdarkly.sdk.server.TestValues.STRING_FLAG_KEY; @@ -143,16 +142,15 @@ public void flagWithPrerequisite(BenchmarkInputs inputs) throws Exception { @Benchmark public void userValueFoundInClauseList(BenchmarkInputs inputs) throws Exception { - LDValue userValue = CLAUSE_MATCH_VALUES.get(inputs.random.nextInt(CLAUSE_MATCH_VALUES.size())); - LDUser user = new LDUser.Builder("key").custom(CLAUSE_MATCH_ATTRIBUTE, userValue).build(); + int i = inputs.random.nextInt(CLAUSE_MATCH_VALUE_COUNT); + LDUser user = TestValues.CLAUSE_MATCH_VALUE_USERS.get(i); boolean result = inputs.client.boolVariation(FLAG_WITH_MULTI_VALUE_CLAUSE_KEY, user, false); assertTrue(result); } @Benchmark public void userValueNotFoundInClauseList(BenchmarkInputs inputs) throws Exception { - LDUser user = new LDUser.Builder("key").custom(CLAUSE_MATCH_ATTRIBUTE, NOT_MATCHED_VALUE).build(); - boolean result = inputs.client.boolVariation(FLAG_WITH_MULTI_VALUE_CLAUSE_KEY, user, false); + boolean result = inputs.client.boolVariation(FLAG_WITH_MULTI_VALUE_CLAUSE_KEY, NOT_MATCHED_VALUE_USER, false); assertFalse(result); } } diff --git a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java index 65258c50c..0a9cee764 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.client.LDUser; import com.launchdarkly.client.value.LDValue; import java.util.ArrayList; @@ -29,14 +30,23 @@ private TestValues() {} public static final String NOT_TARGETED_USER_KEY = "no-match"; public static final String CLAUSE_MATCH_ATTRIBUTE = "clause-match-attr"; + public static final int CLAUSE_MATCH_VALUE_COUNT = 1000; public static final List CLAUSE_MATCH_VALUES; + public static final List CLAUSE_MATCH_VALUE_USERS; static { - CLAUSE_MATCH_VALUES = new ArrayList<>(); + // pre-generate all these values and matching users so this work doesn't count in the evaluation benchmark performance + CLAUSE_MATCH_VALUES = new ArrayList<>(CLAUSE_MATCH_VALUE_COUNT); + CLAUSE_MATCH_VALUE_USERS = new ArrayList<>(CLAUSE_MATCH_VALUE_COUNT); for (int i = 0; i < 1000; i++) { - CLAUSE_MATCH_VALUES.add(LDValue.of("value-" + i)); + LDValue value = LDValue.of("value-" + i); + LDUser user = new LDUser.Builder("key").custom(CLAUSE_MATCH_ATTRIBUTE, value).build(); + CLAUSE_MATCH_VALUES.add(value); + CLAUSE_MATCH_VALUE_USERS.add(user); } } public static final LDValue NOT_MATCHED_VALUE = LDValue.of("no-match"); + public static final LDUser NOT_MATCHED_VALUE_USER = + new LDUser.Builder("key").custom(CLAUSE_MATCH_ATTRIBUTE, NOT_MATCHED_VALUE).build(); public static final String EMPTY_JSON_DATA = "{\"flags\":{},\"segments\":{}}"; } From 846976c8d3bafbb45cad39f9f2919e854224c82b Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:20:17 -0700 Subject: [PATCH 318/327] more configuration tweaks --- benchmarks/build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index c59a18a65..3e4391737 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -34,8 +34,8 @@ jmh { // batchSize = 1 // Batch size: number of benchmark method calls per operation. (some benchmark modes can ignore this setting) fork = 1 // How many times to forks a single benchmark. Use 0 to disable forking altogether // failOnError = false // Should JMH fail immediately if any benchmark had experienced the unrecoverable error? - // forceGC = false // Should JMH force GC between iterations? - // humanOutputFile = project.file("${project.buildDir}/reports/jmh/human.txt") // human-readable output file + forceGC = true // Should JMH force GC between iterations? + humanOutputFile = project.file("${project.buildDir}/reports/jmh/human.txt") // human-readable output file // resultsFile = project.file("${project.buildDir}/reports/jmh/results.txt") // results file operationsPerInvocation = 3 // Operations per invocation. // benchmarkParameters = [:] // Benchmark parameters. @@ -46,7 +46,7 @@ jmh { // threads = 4 // Number of worker threads to run with. // timeout = '1s' // Timeout for benchmark iteration. timeUnit = 'ns' // Output time unit. Available time units are: [m, s, ms, us, ns]. - // verbosity = 'EXTRA' // Verbosity mode. Available modes are: [SILENT, NORMAL, EXTRA] + verbosity = 'NORMAL' // Verbosity mode. Available modes are: [SILENT, NORMAL, EXTRA] warmup = '1s' // Time to spend at each warmup iteration. warmupBatchSize = 2 // Warmup batch size: number of benchmark method calls per operation. warmupIterations = 1 // Number of warmup iterations to do. From db5dc8c93e2ce9a5979bd28b3b96e96dd369d1ae Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:22:33 -0700 Subject: [PATCH 319/327] show more benchmark output --- benchmarks/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/benchmarks/Makefile b/benchmarks/Makefile index dfe0cae8e..d909a42ce 100644 --- a/benchmarks/Makefile +++ b/benchmarks/Makefile @@ -13,6 +13,7 @@ SDK_TEST_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-test.ja benchmark: $(BENCHMARK_ALL_JAR) $(BENCHMARK_TEST_JAR) rm -rf build/tmp ../gradlew jmh + cat build/reports/jmh/human.txt clean: rm -rf build lib From 080c599a11b32f6a6151be63d39a87e71277e5f3 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:32:29 -0700 Subject: [PATCH 320/327] add jmhReport HTML output --- benchmarks/Makefile | 2 +- benchmarks/build.gradle | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/benchmarks/Makefile b/benchmarks/Makefile index d909a42ce..d787a6463 100644 --- a/benchmarks/Makefile +++ b/benchmarks/Makefile @@ -12,7 +12,7 @@ SDK_TEST_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-test.ja benchmark: $(BENCHMARK_ALL_JAR) $(BENCHMARK_TEST_JAR) rm -rf build/tmp - ../gradlew jmh + ../gradlew jmh jmhReport cat build/reports/jmh/human.txt clean: diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 3e4391737..6075ff549 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -8,6 +8,7 @@ buildscript { plugins { id "me.champeau.gradle.jmh" version "0.5.0" + id "io.morethan.jmhreport" version "0.9.0" } repositories { @@ -55,3 +56,8 @@ jmh { jmhVersion = versions.jmh } + +jmhReport { + jmhResultPath = project.file('build/reports/jmh/result.json') + jmhReportOutput = project.file('build/reports/jmh') +} From d80d245d87fc22430c004802abd2f26f8ecc8832 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:40:53 -0700 Subject: [PATCH 321/327] fix report step --- benchmarks/Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/benchmarks/Makefile b/benchmarks/Makefile index d787a6463..d39fff5d9 100644 --- a/benchmarks/Makefile +++ b/benchmarks/Makefile @@ -12,8 +12,9 @@ SDK_TEST_JAR=$(SDK_JARS_DIR)/launchdarkly-java-server-sdk-$(SDK_VERSION)-test.ja benchmark: $(BENCHMARK_ALL_JAR) $(BENCHMARK_TEST_JAR) rm -rf build/tmp - ../gradlew jmh jmhReport + ../gradlew jmh cat build/reports/jmh/human.txt + ../gradlew jmhReport clean: rm -rf build lib From 2bd5a3777326af895dc2c1bf980b9059ec4d21ed Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:56:23 -0700 Subject: [PATCH 322/327] fix event benchmarks so they don't test the construction of the input data --- .../sdk/server/EventProcessorBenchmarks.java | 81 ++++++++----------- .../launchdarkly/sdk/server/TestValues.java | 15 ++++ 2 files changed, 50 insertions(+), 46 deletions(-) diff --git a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java index 3195dae24..a033b1eaf 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/EventProcessorBenchmarks.java @@ -5,7 +5,6 @@ import com.launchdarkly.client.EventProcessor; import com.launchdarkly.client.EventProcessorInternals; import com.launchdarkly.client.LDConfig; -import com.launchdarkly.client.LDUser; import com.launchdarkly.client.interfaces.EventSender; import com.launchdarkly.client.interfaces.EventSenderFactory; import com.launchdarkly.client.interfaces.HttpConfiguration; @@ -17,8 +16,14 @@ import java.io.IOException; import java.net.URI; +import java.util.ArrayList; +import java.util.List; import java.util.Random; +import static com.launchdarkly.sdk.server.TestValues.BASIC_USER; +import static com.launchdarkly.sdk.server.TestValues.CUSTOM_EVENT; +import static com.launchdarkly.sdk.server.TestValues.TEST_EVENTS_COUNT; + public class EventProcessorBenchmarks { private static final int EVENT_BUFFER_SIZE = 1000; private static final int FLAG_COUNT = 10; @@ -30,7 +35,8 @@ public static class BenchmarkInputs { // Initialization of the things in BenchmarkInputs does not count as part of a benchmark. final EventProcessor eventProcessor; final EventSender eventSender; - final LDUser basicUser; + final List featureRequestEventsWithoutTracking = new ArrayList<>(); + final List featureRequestEventsWithTracking = new ArrayList<>(); final Random random; public BenchmarkInputs() { @@ -44,9 +50,30 @@ public BenchmarkInputs() { .eventSender(new MockEventSenderFactory()) .createEventProcessor(TestValues.SDK_KEY, new LDConfig.Builder().build()); - basicUser = new LDUser("userkey"); - random = new Random(); + + for (int i = 0; i < TEST_EVENTS_COUNT; i++) { + String flagKey = "flag" + random.nextInt(FLAG_COUNT); + int version = random.nextInt(FLAG_VERSIONS) + 1; + int variation = random.nextInt(FLAG_VARIATIONS); + for (boolean trackEvents: new boolean[] { false, true }) { + Event.FeatureRequest event = new Event.FeatureRequest( + System.currentTimeMillis(), + flagKey, + BASIC_USER, + version, + variation, + LDValue.of(variation), + LDValue.ofNull(), + null, + null, + trackEvents, + null, + false + ); + (trackEvents ? featureRequestEventsWithTracking : featureRequestEventsWithoutTracking).add(event); + } + } } public String randomFlagKey() { @@ -64,22 +91,7 @@ public int randomFlagVariation() { @Benchmark public void summarizeFeatureRequestEvents(BenchmarkInputs inputs) throws Exception { - for (int i = 0; i < 1000; i++) { - int variation = inputs.randomFlagVariation(); - Event.FeatureRequest event = new Event.FeatureRequest( - System.currentTimeMillis(), - inputs.randomFlagKey(), - inputs.basicUser, - inputs.randomFlagVersion(), - variation, - LDValue.of(variation), - LDValue.ofNull(), - null, - null, - false, // trackEvents == false: only summary counts are generated - null, - false - ); + for (Event.FeatureRequest event: inputs.featureRequestEventsWithoutTracking) { inputs.eventProcessor.sendEvent(event); } inputs.eventProcessor.flush(); @@ -88,22 +100,7 @@ public void summarizeFeatureRequestEvents(BenchmarkInputs inputs) throws Excepti @Benchmark public void featureRequestEventsWithFullTracking(BenchmarkInputs inputs) throws Exception { - for (int i = 0; i < 1000; i++) { - int variation = inputs.randomFlagVariation(); - Event.FeatureRequest event = new Event.FeatureRequest( - System.currentTimeMillis(), - inputs.randomFlagKey(), - inputs.basicUser, - inputs.randomFlagVersion(), - variation, - LDValue.of(variation), - LDValue.ofNull(), - null, - null, - true, // trackEvents == true: the full events are included in the output - null, - false - ); + for (Event.FeatureRequest event: inputs.featureRequestEventsWithTracking) { inputs.eventProcessor.sendEvent(event); } inputs.eventProcessor.flush(); @@ -112,16 +109,8 @@ public void featureRequestEventsWithFullTracking(BenchmarkInputs inputs) throws @Benchmark public void customEvents(BenchmarkInputs inputs) throws Exception { - LDValue data = LDValue.of("data"); - for (int i = 0; i < 1000; i++) { - Event.Custom event = new Event.Custom( - System.currentTimeMillis(), - "event-key", - inputs.basicUser, - data, - null - ); - inputs.eventProcessor.sendEvent(event);; + for (int i = 0; i < TEST_EVENTS_COUNT; i++) { + inputs.eventProcessor.sendEvent(CUSTOM_EVENT); } inputs.eventProcessor.flush(); EventProcessorInternals.waitUntilInactive(inputs.eventProcessor); diff --git a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java index 0a9cee764..fa69c41e1 100644 --- a/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java +++ b/benchmarks/src/jmh/java/com/launchdarkly/sdk/server/TestValues.java @@ -1,5 +1,6 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.client.Event; import com.launchdarkly.client.LDUser; import com.launchdarkly.client.value.LDValue; @@ -11,6 +12,8 @@ private TestValues() {} public static final String SDK_KEY = "sdk-key"; + public static final LDUser BASIC_USER = new LDUser("userkey"); + public static final String BOOLEAN_FLAG_KEY = "flag-bool"; public static final String INT_FLAG_KEY = "flag-int"; public static final String STRING_FLAG_KEY = "flag-string"; @@ -49,4 +52,16 @@ private TestValues() {} new LDUser.Builder("key").custom(CLAUSE_MATCH_ATTRIBUTE, NOT_MATCHED_VALUE).build(); public static final String EMPTY_JSON_DATA = "{\"flags\":{},\"segments\":{}}"; + + public static final int TEST_EVENTS_COUNT = 1000; + + public static final LDValue CUSTOM_EVENT_DATA = LDValue.of("data"); + + public static final Event.Custom CUSTOM_EVENT = new Event.Custom( + System.currentTimeMillis(), + "event-key", + BASIC_USER, + CUSTOM_EVENT_DATA, + null + ); } From 0cefb4e5e2708563da476f05fff76223084460b0 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 22 May 2020 18:56:49 -0700 Subject: [PATCH 323/327] fix data file name --- benchmarks/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/build.gradle b/benchmarks/build.gradle index 6075ff549..b63e654e7 100644 --- a/benchmarks/build.gradle +++ b/benchmarks/build.gradle @@ -58,6 +58,6 @@ jmh { } jmhReport { - jmhResultPath = project.file('build/reports/jmh/result.json') + jmhResultPath = project.file('build/reports/jmh/results.json') jmhReportOutput = project.file('build/reports/jmh') } From f02f8356e7ad02b427dca71963494ebeaaa93366 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 29 May 2020 17:09:38 -0700 Subject: [PATCH 324/327] update metadata so Releaser knows about 4.x branch --- .ldrelease/config.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 09d702867..601d8b378 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -13,6 +13,11 @@ template: env: LD_SKIP_DATABASE_TESTS: 1 +releasableBranches: + - name: master + description: 5.x + - name: 4.x + documentation: githubPages: true From 378b8e8fba147db3119059136a400c25554cfdab Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Tue, 4 Aug 2020 16:56:50 -0700 Subject: [PATCH 325/327] avoid NPEs if LDUser was deserialized by Gson (#257) * avoid NPEs if LDUser was deserialized by Gson * add test --- .../java/com/launchdarkly/client/LDUser.java | 45 ++++++++++--------- .../com/launchdarkly/client/LDUserTest.java | 8 ++++ 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/launchdarkly/client/LDUser.java b/src/main/java/com/launchdarkly/client/LDUser.java index 47d938b1d..0c0fdeb65 100644 --- a/src/main/java/com/launchdarkly/client/LDUser.java +++ b/src/main/java/com/launchdarkly/client/LDUser.java @@ -98,49 +98,49 @@ protected LDValue getValueForEvaluation(String attribute) { } LDValue getKey() { - return key; + return LDValue.normalize(key); } String getKeyAsString() { - return key.stringValue(); + return key == null ? null : key.stringValue(); } // All of the LDValue getters are guaranteed not to return null (although the LDValue may *be* a JSON null). LDValue getIp() { - return ip; + return LDValue.normalize(ip); } LDValue getCountry() { - return country; + return LDValue.normalize(country); } LDValue getSecondary() { - return secondary; + return LDValue.normalize(secondary); } LDValue getName() { - return name; + return LDValue.normalize(name); } LDValue getFirstName() { - return firstName; + return LDValue.normalize(firstName); } LDValue getLastName() { - return lastName; + return LDValue.normalize(lastName); } LDValue getEmail() { - return email; + return LDValue.normalize(email); } LDValue getAvatar() { - return avatar; + return LDValue.normalize(avatar); } LDValue getAnonymous() { - return anonymous; + return LDValue.normalize(anonymous); } LDValue getCustom(String key) { @@ -157,23 +157,24 @@ public boolean equals(Object o) { LDUser ldUser = (LDUser) o; - 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) && + return Objects.equals(getKey(), ldUser.getKey()) && + Objects.equals(getSecondary(), ldUser.getSecondary()) && + Objects.equals(getIp(), ldUser.getIp()) && + Objects.equals(getEmail(), ldUser.getEmail()) && + Objects.equals(getName(), ldUser.getName()) && + Objects.equals(getAvatar(), ldUser.getAvatar()) && + Objects.equals(getFirstName(), ldUser.getFirstName()) && + Objects.equals(getLastName(), ldUser.getLastName()) && + Objects.equals(getAnonymous(), ldUser.getAnonymous()) && + Objects.equals(getCountry(), ldUser.getCountry()) && Objects.equals(custom, ldUser.custom) && Objects.equals(privateAttributeNames, ldUser.privateAttributeNames); } @Override public int hashCode() { - return Objects.hash(key, secondary, ip, email, name, avatar, firstName, lastName, anonymous, country, custom, privateAttributeNames); + return Objects.hash(getKey(), getSecondary(), getIp(), getEmail(), getName(), getAvatar(), getFirstName(), + getLastName(), getAnonymous(), getCountry(), custom, privateAttributeNames); } // Used internally when including users in analytics events, to ensure that private attributes are stripped out. diff --git a/src/test/java/com/launchdarkly/client/LDUserTest.java b/src/test/java/com/launchdarkly/client/LDUserTest.java index fd66966f6..49216608b 100644 --- a/src/test/java/com/launchdarkly/client/LDUserTest.java +++ b/src/test/java/com/launchdarkly/client/LDUserTest.java @@ -494,6 +494,14 @@ public void canAddCustomAttrWithListOfMixedValues() { assertEquals(expectedAttr, jo.get("custom")); } + @Test + public void gsonDeserialization() { + for (Map.Entry e: getUserPropertiesJsonMap().entrySet()) { + LDUser user = TEST_GSON_INSTANCE.fromJson(e.getValue(), LDUser.class); + assertEquals(e.getKey(), user); + } + } + private JsonElement makeCustomAttrWithListOfValues(String name, JsonElement... values) { JsonObject ret = new JsonObject(); JsonArray a = new JsonArray(); From eefc46030a4dd4aea49d6791acf24832b5479fb1 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Mon, 31 Aug 2020 16:42:13 -0700 Subject: [PATCH 326/327] update okhttp to 3.14.9 (fixes incompatibility with OpenJDK 8.0.252) --- build.gradle | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 8edad3a3d..117985b72 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,7 @@ ext.versions = [ "gson": "2.7", "guava": "19.0", "jodaTime": "2.9.3", + "okhttp": "3.14.9", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "1.11.0", "slf4j": "1.7.21", "snakeyaml": "1.19", @@ -71,6 +72,7 @@ libraries.internal = [ "commons-codec:commons-codec:${versions.commonsCodec}", "com.google.guava:guava:${versions.guava}", "joda-time:joda-time:${versions.jodaTime}", + "com.squareup.okhttp3:okhttp:${versions.okhttp}", "com.launchdarkly:okhttp-eventsource:${versions.okhttpEventsource}", "org.yaml:snakeyaml:${versions.snakeyaml}", "redis.clients:jedis:${versions.jedis}" @@ -85,8 +87,8 @@ libraries.external = [ // Add dependencies to "libraries.test" that are used only in unit tests. libraries.test = [ - "com.squareup.okhttp3:mockwebserver:3.12.10", - "com.squareup.okhttp3:okhttp-tls:3.12.10", + "com.squareup.okhttp3:mockwebserver:${versions.okhttp}", + "com.squareup.okhttp3:okhttp-tls:${versions.okhttp}", "org.hamcrest:hamcrest-all:1.3", "org.easymock:easymock:3.4", "junit:junit:4.12", From ac9bdb19979e2f423089f9b656819b323c3c6970 Mon Sep 17 00:00:00 2001 From: ssrm Date: Wed, 2 Sep 2020 20:44:44 -0400 Subject: [PATCH 327/327] Bump SnakeYAML from 1.19 to 1.26 to address CVE-2017-18640 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 117985b72..5df30ac67 100644 --- a/build.gradle +++ b/build.gradle @@ -61,7 +61,7 @@ ext.versions = [ "okhttp": "3.14.9", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "1.11.0", "slf4j": "1.7.21", - "snakeyaml": "1.19", + "snakeyaml": "1.26", "jedis": "2.9.0" ]